--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit fda9187e95d8b9cc8373c1de23cefce6928c241d
Parents : 5694c1e
Author : Sudo-Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-01-05T11:47:35-06:00
numerous improvements
Changes
104 files changed, 4567 insertions(+), 1070 deletions(-)
Diff
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 39670801..d5a2c266 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -2,7 +2,6 @@
import argparse
import asyncio
-import aiohttp
import atexit
import base64
import configparser
@@ -12,6 +11,7 @@ import hashlib
import importlib.metadata
import ipaddress
import json
+import logging
import os
import platform
import secrets
@@ -25,7 +25,9 @@ import time
import traceback
import webbrowser
from datetime import UTC, datetime, timedelta
+from logging.handlers import RotatingFileHandler
+import aiohttp
import bcrypt
import LXMF
import LXST
@@ -35,7 +37,6 @@ from aiohttp import WSCloseCode, WSMessage, WSMsgType, web
from aiohttp_session import get_session
from aiohttp_session import setup as setup_session
from aiohttp_session.cookie_storage import EncryptedCookieStorage
-from logging.handlers import RotatingFileHandler
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
@@ -49,10 +50,18 @@ from meshchatx.src.backend.identity_context import IdentityContext
from meshchatx.src.backend.identity_manager import IdentityManager
from meshchatx.src.backend.interface_config_parser import InterfaceConfigParser
from meshchatx.src.backend.interface_editor import InterfaceEditor
+from meshchatx.src.backend.lxmf_message_fields import (
+ LxmfAudioField,
+ LxmfFileAttachment,
+ LxmfFileAttachmentsField,
+ LxmfImageField,
+)
from meshchatx.src.backend.lxmf_utils import (
convert_db_lxmf_message_to_dict,
convert_lxmf_message_to_dict,
)
+from meshchatx.src.backend.map_manager import TRANSPARENT_TILE
+from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
from meshchatx.src.backend.meshchat_utils import (
convert_db_favourite_to_dict,
convert_propagation_node_state_to_string,
@@ -64,14 +73,6 @@ from meshchatx.src.backend.meshchat_utils import (
parse_lxmf_stamp_cost,
parse_nomadnetwork_node_display_name,
)
-from meshchatx.src.backend.lxmf_message_fields import (
- LxmfAudioField,
- LxmfFileAttachment,
- LxmfFileAttachmentsField,
- LxmfImageField,
-)
-from meshchatx.src.backend.map_manager import TRANSPARENT_TILE
-from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
from meshchatx.src.backend.nomadnet_downloader import (
NomadnetFileDownloader,
NomadnetPageDownloader,
@@ -89,8 +90,6 @@ from meshchatx.src.backend.telemetry_utils import Telemeter
from meshchatx.src.backend.web_audio_bridge import WebAudioBridge
from meshchatx.src.version import __version__ as app_version
-import logging
-
def resolve_log_dir():
"""Choose a writable log directory across container, desktop, and Windows."""
@@ -453,6 +452,15 @@ class ReticulumMeshChat:
if self.current_context:
self.current_context.translator_handler = value
+ @property
+ def bot_handler(self):
+ return self.current_context.bot_handler if self.current_context else None
+
+ @bot_handler.setter
+ def bot_handler(self, value):
+ if self.current_context:
+ self.current_context.bot_handler = value
+
@property
def forwarding_manager(self):
return self.current_context.forwarding_manager if self.current_context else None
@@ -561,7 +569,8 @@ class ReticulumMeshChat:
self.current_context = context
context.setup()
self.web_audio_bridge = WebAudioBridge(
- context.telephone_manager, context.config
+ context.telephone_manager,
+ context.config,
)
# Link database to memory log handler
@@ -920,14 +929,14 @@ class ReticulumMeshChat:
current_laddr == target_addr
or (
target_addr.startswith(
- b"\0"
+ b"\0",
)
and current_laddr
== target_addr[1:]
)
or (
current_laddr.startswith(
- b"\0"
+ b"\0",
)
and target_addr
== current_laddr[1:]
@@ -1122,16 +1131,19 @@ class ReticulumMeshChat:
# FINAL FAILSAFE: Create a brand new identity
try:
print(
- "CRITICAL: Restoration of previous identity failed. Creating a brand new emergency identity..."
+ "CRITICAL: Restoration of previous identity failed. Creating a brand new emergency identity...",
)
new_id_data = self.create_identity(
- display_name="Emergency Recovery"
+ display_name="Emergency Recovery",
)
new_id_hash = new_id_data["hash"]
# Try to load the newly created identity
emergency_identity_file = os.path.join(
- self.storage_dir, "identities", new_id_hash, "identity"
+ self.storage_dir,
+ "identities",
+ new_id_hash,
+ "identity",
)
emergency_id = RNS.Identity.from_file(emergency_identity_file)
@@ -1143,12 +1155,12 @@ class ReticulumMeshChat:
print(f"Emergency identity created and loaded: {new_id_hash}")
else:
raise RuntimeError(
- "Failed to load newly created emergency identity"
+ "Failed to load newly created emergency identity",
)
except Exception as final_err:
print(
- f"ULTIMATE FAILURE: Could not even create emergency identity: {final_err}"
+ f"ULTIMATE FAILURE: Could not even create emergency identity: {final_err}",
)
traceback.print_exc()
@@ -1164,7 +1176,7 @@ class ReticulumMeshChat:
return self.identity_manager.list_identities(
self.identity.hash.hex()
if hasattr(self, "identity") and self.identity
- else None
+ else None,
)
def create_identity(self, display_name=None):
@@ -1532,7 +1544,7 @@ class ReticulumMeshChat:
ctx.message_router.disable_propagation()
except Exception:
print(
- f"failed to enable or disable propagation node for {ctx.identity_hash}"
+ f"failed to enable or disable propagation node for {ctx.identity_hash}",
)
def _get_reticulum_section(self):
@@ -1688,7 +1700,11 @@ class ReticulumMeshChat:
return matches
def on_new_voicemail_received(
- self, remote_hash, remote_name, duration, context=None
+ self,
+ remote_hash,
+ remote_name,
+ duration,
+ context=None,
):
ctx = context or self.current_context
if not ctx:
@@ -1723,7 +1739,7 @@ class ReticulumMeshChat:
if ctx.telephone_manager and ctx.telephone_manager.initiation_status:
print(
- "on_incoming_telephone_call: Ignoring as we are currently initiating an outgoing call."
+ "on_incoming_telephone_call: Ignoring as we are currently initiating an outgoing call.",
)
return
@@ -1774,7 +1790,9 @@ class ReticulumMeshChat:
)
def on_telephone_call_established(
- self, caller_identity: RNS.Identity, context=None
+ self,
+ caller_identity: RNS.Identity,
+ context=None,
):
ctx = context or self.current_context
if not ctx:
@@ -1802,8 +1820,8 @@ class ReticulumMeshChat:
)
try:
self.web_audio_bridge.on_call_ended()
- except Exception:
- pass
+ except Exception as e:
+ logging.exception(f"Error in web_audio_bridge.on_call_ended: {e}")
# Record call history
if caller_identity:
@@ -1894,7 +1912,7 @@ class ReticulumMeshChat:
if target_hash:
try:
contact = ctx.database.contacts.get_contact_by_identity_hash(
- target_hash
+ target_hash,
)
if contact:
target_name = contact.name
@@ -2115,7 +2133,7 @@ class ReticulumMeshChat:
"total": total,
"limit": limit,
"offset": offset,
- }
+ },
)
@routes.post("/api/v1/database/snapshot")
@@ -2127,7 +2145,8 @@ class ReticulumMeshChat:
return web.json_response({"status": "success", "result": result})
except Exception as e:
return web.json_response(
- {"status": "error", "message": str(e)}, status=500
+ {"status": "error", "message": str(e)},
+ status=500,
)
@routes.get("/api/v1/database/snapshots")
@@ -2137,7 +2156,8 @@ class ReticulumMeshChat:
return web.json_response(snapshots)
except Exception as e:
return web.json_response(
- {"status": "error", "message": str(e)}, status=500
+ {"status": "error", "message": str(e)},
+ status=500,
)
@routes.post("/api/v1/database/restore")
@@ -2147,7 +2167,8 @@ class ReticulumMeshChat:
path = data.get("path")
if not path:
return web.json_response(
- {"status": "error", "message": "No path provided"}, status=400
+ {"status": "error", "message": "No path provided"},
+ status=400,
)
# Verify path is within storage_dir/snapshots or provided directly
@@ -2167,11 +2188,12 @@ class ReticulumMeshChat:
result = self.database.restore_database(path)
# Note: This might require an app relaunch to be fully effective
return web.json_response(
- {"status": "success", "result": result, "requires_relaunch": True}
+ {"status": "success", "result": result, "requires_relaunch": True},
)
except Exception as e:
return web.json_response(
- {"status": "error", "message": str(e)}, status=500
+ {"status": "error", "message": str(e)},
+ status=500,
)
@routes.get("/api/v1/database/backups")
@@ -2192,16 +2214,18 @@ class ReticulumMeshChat:
"path": full_path,
"size": stats.st_size,
"created_at": datetime.fromtimestamp(
- stats.st_mtime, UTC
+ stats.st_mtime,
+ UTC,
).isoformat(),
- }
+ },
)
return web.json_response(
- sorted(backups, key=lambda x: x["created_at"], reverse=True)
+ sorted(backups, key=lambda x: x["created_at"], reverse=True),
)
except Exception as e:
return web.json_response(
- {"status": "error", "message": str(e)}, status=500
+ {"status": "error", "message": str(e)},
+ status=500,
)
@routes.get("/api/v1/status")
@@ -2375,7 +2399,7 @@ class ReticulumMeshChat:
body=data,
content_type="application/zip",
headers={
- "Content-Disposition": f'attachment; filename="{filename}"'
+ "Content-Disposition": f'attachment; filename="{filename}"',
},
)
except Exception as e:
@@ -2988,6 +3012,24 @@ class ReticulumMeshChat:
interface_details["command"] = interface_command
interface_details["respawn_delay"] = interface_respawn_delay
+ # interface discovery options
+ for discovery_key in (
+ "discoverable",
+ "discovery_name",
+ "announce_interval",
+ "reachable_on",
+ "discovery_stamp_value",
+ "discovery_encrypt",
+ "publish_ifac",
+ "latitude",
+ "longitude",
+ "height",
+ "discovery_frequency",
+ "discovery_bandwidth",
+ "discovery_modulation",
+ ):
+ InterfaceEditor.update_value(interface_details, data, discovery_key)
+
# set common interface options
InterfaceEditor.update_value(interface_details, data, "bitrate")
InterfaceEditor.update_value(interface_details, data, "mode")
@@ -3236,10 +3278,12 @@ class ReticulumMeshChat:
self.web_audio_bridge.attach_client(websocket_response)
elif data.get("type") == "ping":
await websocket_response.send_str(
- json.dumps({"type": "pong"})
+ json.dumps({"type": "pong"}),
)
- except Exception:
- pass
+ except Exception as e:
+ logging.exception(
+ f"Error processing websocket text message: {e}",
+ )
elif msg.type == WSMsgType.ERROR:
print(f"telephone audio ws error {websocket_response.exception()}")
@@ -3303,7 +3347,7 @@ class ReticulumMeshChat:
"dependencies": {
"aiohttp": self.get_package_version("aiohttp"),
"aiohttp_session": self.get_package_version(
- "aiohttp-session"
+ "aiohttp-session",
),
"cryptography": self.get_package_version("cryptography"),
"psutil": self.get_package_version("psutil"),
@@ -3414,7 +3458,11 @@ class ReticulumMeshChat:
html_content = MarkdownRenderer.render(content)
return web.json_response(
- {"changelog": content, "html": html_content, "version": app_version}
+ {
+ "changelog": content,
+ "html": html_content,
+ "version": app_version,
+ },
)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@@ -3432,7 +3480,7 @@ class ReticulumMeshChat:
self.current_context.integrity_manager.save_manifest()
self.integrity_issues = []
return web.json_response(
- {"message": "Integrity issues acknowledged and manifest reset"}
+ {"message": "Integrity issues acknowledged and manifest reset"},
)
# mark changelog as seen
@@ -3445,7 +3493,7 @@ class ReticulumMeshChat:
self.config.set("changelog_seen_version", version)
return web.json_response(
- {"message": f"Changelog version {version} marked as seen"}
+ {"message": f"Changelog version {version} marked as seen"},
)
# shutdown app
@@ -3480,7 +3528,8 @@ class ReticulumMeshChat:
field = await reader.next()
if field.name != "file":
return web.json_response(
- {"error": "No file field in multipart request"}, status=400
+ {"error": "No file field in multipart request"},
+ status=400,
)
version = request.query.get("version")
@@ -3502,7 +3551,8 @@ class ReticulumMeshChat:
version = data.get("version")
if not version:
return web.json_response(
- {"error": "No version provided"}, status=400
+ {"error": "No version provided"},
+ status=400,
)
success = self.docs_manager.switch_version(version)
@@ -3548,7 +3598,7 @@ class ReticulumMeshChat:
body=zip_data,
content_type="application/zip",
headers={
- "Content-Disposition": f'attachment; filename="{filename}"'
+ "Content-Disposition": f'attachment; filename="{filename}"',
},
)
except Exception as e:
@@ -3838,7 +3888,8 @@ class ReticulumMeshChat:
# attempt hotswap first
success = await self.hotswap_identity(
- identity_hash, keep_alive=keep_alive
+ identity_hash,
+ keep_alive=keep_alive,
)
if success:
@@ -3901,16 +3952,96 @@ class ReticulumMeshChat:
@routes.patch("/api/v1/config")
async def config_update(request):
# get request body as json
- data = await request.json()
+ try:
+ data = await request.json()
+ await self.update_config(data)
+ try:
+ AsyncUtils.run_async(self.send_config_to_websocket_clients())
+ except Exception as e:
+ print(f"Failed to broadcast config update: {e}")
- # update config
- await self.update_config(data)
+ return web.json_response(
+ {
+ "config": self.get_config_dict(),
+ },
+ )
+ except Exception:
+ import traceback
- return web.json_response(
- {
- "config": self.get_config_dict(),
- },
- )
+ print("config_update failed:\n" + traceback.format_exc())
+ return web.json_response({"error": "config_update_failed"}, status=500)
+
+ # get or update reticulum discovery configuration
+ @routes.get("/api/v1/reticulum/discovery")
+ async def reticulum_discovery_get(request):
+ reticulum_config = self._get_reticulum_section()
+ discovery_config = {
+ "discover_interfaces": reticulum_config.get("discover_interfaces"),
+ "interface_discovery_sources": reticulum_config.get(
+ "interface_discovery_sources",
+ ),
+ "required_discovery_value": reticulum_config.get(
+ "required_discovery_value",
+ ),
+ "autoconnect_discovered_interfaces": reticulum_config.get(
+ "autoconnect_discovered_interfaces",
+ ),
+ "network_identity": reticulum_config.get("network_identity"),
+ }
+
+ return web.json_response({"discovery": discovery_config})
+
+ @routes.patch("/api/v1/reticulum/discovery")
+ async def reticulum_discovery_patch(request):
+ try:
+ data = await request.json()
+ except Exception:
+ return web.json_response(
+ {"message": "Invalid request body"},
+ status=400,
+ )
+
+ reticulum_config = self._get_reticulum_section()
+
+ def update_config_value(key):
+ if key not in data:
+ return
+ value = data.get(key)
+ if value is None or value == "":
+ reticulum_config.pop(key, None)
+ else:
+ reticulum_config[key] = value
+
+ for key in (
+ "discover_interfaces",
+ "interface_discovery_sources",
+ "required_discovery_value",
+ "autoconnect_discovered_interfaces",
+ "network_identity",
+ ):
+ update_config_value(key)
+
+ if not self._write_reticulum_config():
+ return web.json_response(
+ {"message": "Failed to write Reticulum config"},
+ status=500,
+ )
+
+ discovery_config = {
+ "discover_interfaces": reticulum_config.get("discover_interfaces"),
+ "interface_discovery_sources": reticulum_config.get(
+ "interface_discovery_sources",
+ ),
+ "required_discovery_value": reticulum_config.get(
+ "required_discovery_value",
+ ),
+ "autoconnect_discovered_interfaces": reticulum_config.get(
+ "autoconnect_discovered_interfaces",
+ ),
+ "network_identity": reticulum_config.get("network_identity"),
+ }
+
+ return web.json_response({"discovery": discovery_config})
# enable transport mode
@routes.post("/api/v1/reticulum/enable-transport")
@@ -4009,10 +4140,12 @@ class ReticulumMeshChat:
# remote_identity is already fetched and checked for None above
remote_hash = remote_identity.hash.hex()
remote_destination_hash = RNS.Destination.hash(
- remote_identity, "lxmf", "delivery"
+ remote_identity,
+ "lxmf",
+ "delivery",
).hex()
remote_telephony_hash = self.get_lxst_telephony_hash_for_identity_hash(
- remote_hash
+ remote_hash,
)
remote_name = None
if self.telephone_manager.get_name_for_identity_hash:
@@ -4031,7 +4164,7 @@ class ReticulumMeshChat:
# Check if contact and get custom image
contact = self.database.contacts.get_contact_by_identity_hash(
- remote_hash
+ remote_hash,
)
custom_image = contact["custom_image"] if contact else None
@@ -4047,7 +4180,8 @@ class ReticulumMeshChat:
"remote_telephony_hash": remote_telephony_hash,
"audio_profile_id": self.telephone_manager.telephone.transmit_codec.profile
if hasattr(
- self.telephone_manager.telephone.transmit_codec, "profile"
+ self.telephone_manager.telephone.transmit_codec,
+ "profile",
)
else None,
"is_recording": self.telephone_manager.is_recording,
@@ -4071,7 +4205,7 @@ class ReticulumMeshChat:
if initiation_target_hash:
try:
contact = self.database.contacts.get_contact_by_identity_hash(
- initiation_target_hash
+ initiation_target_hash,
)
if contact:
initiation_target_name = contact.name
@@ -4223,7 +4357,7 @@ class ReticulumMeshChat:
or d.get("remote_identity_name") == "Unknown"
):
resolved_name = self.get_name_for_identity_hash(
- remote_identity_hash
+ remote_identity_hash,
)
if resolved_name:
d["remote_identity_name"] = resolved_name
@@ -4232,7 +4366,7 @@ class ReticulumMeshChat:
remote_identity_hash,
)
tele_hash = self.get_lxst_telephony_hash_for_identity_hash(
- remote_identity_hash
+ remote_identity_hash,
)
if lxmf_hash:
d["remote_destination_hash"] = lxmf_hash
@@ -4241,11 +4375,13 @@ class ReticulumMeshChat:
d["remote_icon"] = dict(icon)
if tele_hash:
d["remote_telephony_hash"] = tele_hash
- d["is_contact"] = bool(
- self.database.contacts.get_contact_by_identity_hash(
- remote_identity_hash,
- ),
+
+ contact = self.database.contacts.get_contact_by_identity_hash(
+ remote_identity_hash,
)
+ d["is_contact"] = contact is not None
+ if contact:
+ d["contact_image"] = contact.get("custom_image")
call_history.append(d)
return web.json_response(
@@ -4267,7 +4403,8 @@ class ReticulumMeshChat:
try:
if self.telephone_manager.telephone is None:
return web.json_response(
- {"message": "Telephone not initialized"}, status=400
+ {"message": "Telephone not initialized"},
+ status=400,
)
await asyncio.to_thread(
@@ -4328,7 +4465,8 @@ class ReticulumMeshChat:
async def _initiate():
try:
await self.telephone_manager.initiate(
- identity_hash_bytes, timeout_seconds=timeout_seconds
+ identity_hash_bytes,
+ timeout_seconds=timeout_seconds,
)
except Exception as e:
print(f"Failed to initiate call to {identity_hash_hex}: {e}")
@@ -4412,7 +4550,7 @@ class ReticulumMeshChat:
remote_identity_hash,
)
tele_hash = self.get_lxst_telephony_hash_for_identity_hash(
- remote_identity_hash
+ remote_identity_hash,
)
if lxmf_hash:
d["remote_destination_hash"] = lxmf_hash
@@ -4462,7 +4600,8 @@ class ReticulumMeshChat:
)
if os.path.exists(filepath):
return web.FileResponse(
- filepath, headers={"Content-Type": "audio/opus"}
+ filepath,
+ headers={"Content-Type": "audio/opus"},
)
return web.json_response(
{"message": "Greeting audio not found"},
@@ -4477,12 +4616,14 @@ class ReticulumMeshChat:
voicemail_id = int(voicemail_id)
except (ValueError, TypeError):
return web.json_response(
- {"message": "Invalid voicemail ID"}, status=400
+ {"message": "Invalid voicemail ID"},
+ status=400,
)
if not self.voicemail_manager:
return web.json_response(
- {"message": "Voicemail manager not available"}, status=503
+ {"message": "Voicemail manager not available"},
+ status=503,
)
voicemail = self.database.voicemails.get_voicemail(voicemail_id)
@@ -4494,7 +4635,8 @@ class ReticulumMeshChat:
if os.path.exists(filepath):
# Browsers might need a proper content type for .opus files
return web.FileResponse(
- filepath, headers={"Content-Type": "audio/opus"}
+ filepath,
+ headers={"Content-Type": "audio/opus"},
)
RNS.log(
f"Voicemail: Recording file missing for ID {voicemail_id}: {filepath}",
@@ -4540,7 +4682,8 @@ class ReticulumMeshChat:
recording_id = int(recording_id)
except (ValueError, TypeError):
return web.json_response(
- {"message": "Invalid recording ID"}, status=400
+ {"message": "Invalid recording ID"},
+ status=400,
)
side = request.match_info.get("side") # rx or tx
@@ -4549,7 +4692,8 @@ class ReticulumMeshChat:
filename = recording[f"filename_{side}"]
if not filename:
return web.json_response(
- {"message": f"No {side} recording found"}, status=404
+ {"message": f"No {side} recording found"},
+ status=404,
)
filepath = os.path.join(
@@ -4558,7 +4702,8 @@ class ReticulumMeshChat:
)
if os.path.exists(filepath):
return web.FileResponse(
- filepath, headers={"Content-Type": "audio/opus"}
+ filepath,
+ headers={"Content-Type": "audio/opus"},
)
return web.json_response({"message": "Recording not found"}, status=404)
@@ -4677,7 +4822,7 @@ class ReticulumMeshChat:
# 1. check contact preferred ringtone
if caller_hash:
contact = self.database.contacts.get_contact_by_identity_hash(
- caller_hash
+ caller_hash,
)
if contact and contact.get("preferred_ringtone_id"):
ringtone_id = contact["preferred_ringtone_id"]
@@ -4866,7 +5011,7 @@ class ReticulumMeshChat:
remote_identity_hash,
)
tele_hash = self.get_lxst_telephony_hash_for_identity_hash(
- remote_identity_hash
+ remote_identity_hash,
)
if lxmf_hash:
d["remote_destination_hash"] = lxmf_hash
@@ -4884,20 +5029,43 @@ class ReticulumMeshChat:
data = await request.json()
name = data.get("name")
remote_identity_hash = data.get("remote_identity_hash")
+ lxmf_address = data.get("lxmf_address")
+ lxst_address = data.get("lxst_address")
preferred_ringtone_id = data.get("preferred_ringtone_id")
custom_image = data.get("custom_image")
- if not name or not remote_identity_hash:
+ if not name:
return web.json_response(
- {"message": "Name and identity hash required"},
+ {"message": "Name is required"},
+ status=400,
+ )
+
+ if not remote_identity_hash:
+ # Try to derive identity from LXMF or LXST address
+ lookup_hash = lxmf_address or lxst_address
+ if lookup_hash:
+ announce = self.database.announces.get_announce_by_hash(lookup_hash)
+ if announce:
+ remote_identity_hash = announce.get("identity_hash")
+ else:
+ # try to recall identity from RNS
+ ident = self.recall_identity(lookup_hash)
+ if ident:
+ remote_identity_hash = ident.hash.hex()
+
+ if not remote_identity_hash:
+ return web.json_response(
+ {"message": "Identity hash is required or could not be derived"},
status=400,
)
self.database.contacts.add_contact(
name,
remote_identity_hash,
- preferred_ringtone_id,
- custom_image,
+ lxmf_address=lxmf_address,
+ lxst_address=lxst_address,
+ preferred_ringtone_id=preferred_ringtone_id,
+ custom_image=custom_image,
)
return web.json_response({"message": "Contact added"})
@@ -4907,17 +5075,21 @@ class ReticulumMeshChat:
data = await request.json()
name = data.get("name")
remote_identity_hash = data.get("remote_identity_hash")
+ lxmf_address = data.get("lxmf_address")
+ lxst_address = data.get("lxst_address")
preferred_ringtone_id = data.get("preferred_ringtone_id")
custom_image = data.get("custom_image")
clear_image = data.get("clear_image", False)
self.database.contacts.update_contact(
contact_id,
- name,
- remote_identity_hash,
- preferred_ringtone_id,
- custom_image,
- clear_image,
+ name=name,
+ remote_identity_hash=remote_identity_hash,
+ lxmf_address=lxmf_address,
+ lxst_address=lxst_address,
+ preferred_ringtone_id=preferred_ringtone_id,
+ custom_image=custom_image,
+ clear_image=clear_image,
)
return web.json_response({"message": "Contact updated"})
@@ -5019,8 +5191,8 @@ class ReticulumMeshChat:
r["identity_hash"]
for r in results
if r.get("identity_hash")
- ]
- )
+ ],
+ ),
)
if identity_hashes:
lxmf_results = self.database.announces.provider.fetchall(
@@ -5050,14 +5222,14 @@ class ReticulumMeshChat:
display_name = parse_lxmf_display_name(announce["app_data"])
elif announce["aspect"] == "nomadnetwork.node":
display_name = parse_nomadnetwork_node_display_name(
- announce["app_data"]
+ announce["app_data"],
)
elif announce["aspect"] == "lxst.telephony":
display_name = parse_lxmf_display_name(announce["app_data"])
if not display_name or display_name == "Anonymous Peer":
# Try pre-fetched LXMF name
display_name = lxmf_names_for_telephony.get(
- announce["identity_hash"]
+ announce["identity_hash"],
)
if not display_name or display_name == "Anonymous Peer":
@@ -5072,7 +5244,7 @@ class ReticulumMeshChat:
# get current hops away
hops = RNS.Transport.hops_to(
- bytes.fromhex(announce["destination_hash"])
+ bytes.fromhex(announce["destination_hash"]),
)
# ensure created_at and updated_at have Z suffix
@@ -5099,10 +5271,11 @@ class ReticulumMeshChat:
"updated_at": updated_at,
"display_name": display_name,
"custom_display_name": custom_names.get(
- announce["destination_hash"]
+ announce["destination_hash"],
),
"lxmf_user_icon": user_icons.get(announce["destination_hash"]),
- }
+ "contact_image": announce.get("contact_image"),
+ },
)
# apply search query filter if provided
@@ -5942,7 +6115,8 @@ class ReticulumMeshChat:
destination_hash = data.get("destination_hash")
if not destination_hash:
return web.json_response(
- {"message": "destination_hash is required"}, status=400
+ {"message": "destination_hash is required"},
+ status=400,
)
try:
success = self.rnpath_handler.drop_path(destination_hash)
@@ -5956,7 +6130,8 @@ class ReticulumMeshChat:
transport_instance_hash = data.get("transport_instance_hash")
if not transport_instance_hash:
return web.json_response(
- {"message": "transport_instance_hash is required"}, status=400
+ {"message": "transport_instance_hash is required"},
+ status=400,
)
try:
success = self.rnpath_handler.drop_all_via(transport_instance_hash)
@@ -5978,7 +6153,8 @@ class ReticulumMeshChat:
destination_hash = data.get("destination_hash")
if not destination_hash:
return web.json_response(
- {"message": "destination_hash is required"}, status=400
+ {"message": "destination_hash is required"},
+ status=400,
)
try:
success = self.rnpath_handler.request_path(destination_hash)
@@ -6097,6 +6273,140 @@ class ReticulumMeshChat:
status=500,
)
+ @routes.get("/api/v1/bots/status")
+ async def bots_status(request):
+ try:
+ status = self.bot_handler.get_status()
+ templates = self.bot_handler.get_available_templates()
+ return web.json_response(
+ {
+ "status": status,
+ "templates": templates,
+ "detection_error": status.get("detection_error"),
+ },
+ )
+ except Exception as e:
+ return web.json_response(
+ {"message": str(e)},
+ status=500,
+ )
+
+ @routes.post("/api/v1/bots/start")
+ async def bots_start(request):
+ data = await request.json()
+ template_id = data.get("template_id")
+ name = data.get("name")
+ bot_id = data.get("bot_id")
+
+ if not template_id:
+ return web.json_response(
+ {"message": "template_id is required"},
+ status=400,
+ )
+
+ try:
+ bot_id = self.bot_handler.start_bot(
+ template_id,
+ name=name,
+ bot_id=bot_id,
+ )
+ return web.json_response({"bot_id": bot_id, "success": True})
+ except Exception as e:
+ return web.json_response(
+ {"message": str(e)},
+ status=500,
+ )
+
+ @routes.post("/api/v1/bots/stop")
+ async def bots_stop(request):
+ data = await request.json()
+ bot_id = data.get("bot_id")
+
+ if not bot_id:
+ return web.json_response(
+ {"message": "bot_id is required"},
+ status=400,
+ )
+
+ try:
+ success = self.bot_handler.stop_bot(bot_id)
+ return web.json_response({"success": success})
+ except Exception as e:
+ return web.json_response(
+ {"message": str(e)},
+ status=500,
+ )
+
+ @routes.post("/api/v1/bots/restart")
+ async def bots_restart(request):
+ data = await request.json()
+ bot_id = data.get("bot_id")
+
+ if not bot_id:
+ return web.json_response(
+ {"message": "bot_id is required"},
+ status=400,
+ )
+
+ try:
+ new_id = self.bot_handler.restart_bot(bot_id)
+ return web.json_response({"bot_id": new_id, "success": True})
+ except Exception as e:
+ return web.json_response(
+ {"message": str(e)},
+ status=500,
+ )
+
+ @routes.post("/api/v1/bots/delete")
+ async def bots_delete(request):
+ data = await request.json()
+ bot_id = data.get("bot_id")
+
+ if not bot_id:
+ return web.json_response(
+ {"message": "bot_id is required"},
+ status=400,
+ )
+
+ try:
+ success = self.bot_handler.delete_bot(bot_id)
+ return web.json_response({"success": success})
+ except Exception as e:
+ return web.json_response(
+ {"message": str(e)},
+ status=500,
+ )
+
+ @routes.get("/api/v1/bots/export")
+ async def bots_export(request):
+ bot_id = request.query.get("bot_id")
+
+ if not bot_id:
+ return web.json_response(
+ {"message": "bot_id is required"},
+ status=400,
+ )
+
+ try:
+ id_path = self.bot_handler.get_bot_identity_path(bot_id)
+ if not id_path or not os.path.exists(id_path):
+ return web.json_response(
+ {"message": "Identity file not found"},
+ status=404,
+ )
+
+ return web.FileResponse(
+ id_path,
+ headers={
+ "Content-Disposition": f'attachment; filename="bot_{bot_id}_identity"',
+ },
+ )
+ except Exception as e:
+ return web.json_response(
+ {"message": str(e)},
+ status=500,
+ )
+
# get custom destination display name
@routes.get("/api/v1/destination/{destination_hash}/custom-display-name")
async def destination_custom_display_name_get(request):
@@ -6645,7 +6955,7 @@ class ReticulumMeshChat:
display_name = "Anonymous Peer"
if row["peer_app_data"]:
display_name = parse_lxmf_display_name(
- app_data_base64=row["peer_app_data"]
+ app_data_base64=row["peer_app_data"],
)
# user icon
@@ -6692,14 +7002,15 @@ class ReticulumMeshChat:
"is_unread": is_unread,
"failed_messages_count": row["failed_count"],
"has_attachments": message_fields_have_attachments(
- row["fields"]
+ row["fields"],
),
"latest_message_title": row["title"],
"latest_message_preview": row["content"],
"latest_message_created_at": row["timestamp"],
"lxmf_user_icon": user_icon,
"updated_at": datetime.fromtimestamp(
- row["timestamp"], UTC
+ row["timestamp"],
+ UTC,
).isoformat(),
},
)
@@ -6933,7 +7244,7 @@ class ReticulumMeshChat:
# Try to resolve identity hash from destination hash
identity_hash = None
announce = self.database.announces.get_announce_by_hash(
- destination_hash
+ destination_hash,
)
if announce and announce.get("identity_hash"):
identity_hash = announce["identity_hash"]
@@ -6950,7 +7261,8 @@ class ReticulumMeshChat:
else "Blocked in MeshChatX"
)
self.reticulum.blackhole_identity(
- dest_bytes, reason=reason
+ dest_bytes,
+ reason=reason,
)
else:
# fallback to dropping path
@@ -6992,7 +7304,7 @@ class ReticulumMeshChat:
# Try to resolve identity hash from destination hash
identity_hash = None
announce = self.database.announces.get_announce_by_hash(
- destination_hash
+ destination_hash,
)
if announce and announce.get("identity_hash"):
identity_hash = announce["identity_hash"]
@@ -7014,7 +7326,8 @@ class ReticulumMeshChat:
async def reticulum_blackhole_get(request):
if not hasattr(self, "reticulum") or not self.reticulum:
return web.json_response(
- {"error": "Reticulum not initialized"}, status=503
+ {"error": "Reticulum not initialized"},
+ status=503,
)
try:
@@ -7031,8 +7344,7 @@ class ReticulumMeshChat:
"reason": info.get("reason"),
}
return web.json_response({"blackholed_identities": formatted})
- else:
- return web.json_response({"blackholed_identities": {}})
+ return web.json_response({"blackholed_identities": {}})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@@ -7431,7 +7743,7 @@ class ReticulumMeshChat:
# Allow framing for docs and rnode flasher
if request.path.startswith("/reticulum-docs/") or request.path.startswith(
- "/rnode-flasher/"
+ "/rnode-flasher/",
):
response.headers["X-Frame-Options"] = "SAMEORIGIN"
else:
@@ -7664,7 +7976,7 @@ class ReticulumMeshChat:
try:
if not self.emergency:
print(
- f"Performing scheduled auto-backup for {ctx.identity_hash}..."
+ f"Performing scheduled auto-backup for {ctx.identity_hash}...",
)
ctx.database.backup_database(self.storage_dir)
except Exception as e:
@@ -7905,7 +8217,12 @@ class ReticulumMeshChat:
self.config.map_default_lon.set(str(data["map_default_lon"]))
if "map_default_zoom" in data:
- self.config.map_default_zoom.set(int(data["map_default_zoom"]))
+ try:
+ value = int(data["map_default_zoom"])
+ except (TypeError, ValueError):
+ value = None
+ if value is not None:
+ self.config.map_default_zoom.set(value)
if "map_mbtiles_dir" in data:
self.config.map_mbtiles_dir.set(data["map_mbtiles_dir"])
@@ -8085,7 +8402,7 @@ class ReticulumMeshChat:
):
ctx = context or self.current_context
if not ctx:
- return
+ return None
return ctx.nomadnet_manager.archive_page(
destination_hash,
page_path,
@@ -8105,7 +8422,12 @@ class ReticulumMeshChat:
# handle data received from websocket client
async def on_websocket_data_received(self, client, data):
# get type from client data
- _type = data["type"]
+ if not isinstance(data, dict):
+ return
+
+ _type = data.get("type")
+ if not _type:
+ return
# handle ping
if _type == "ping":
@@ -8124,13 +8446,23 @@ class ReticulumMeshChat:
# get config from websocket
config = data["config"]
- # update config
- await self.update_config(config)
+ try:
+ await self.update_config(config)
+ try:
+ AsyncUtils.run_async(self.send_config_to_websocket_clients())
+ except Exception as e:
+ print(f"Failed to broadcast config update: {e}")
+ except Exception:
+ import traceback
+
+ print("config.set failed:\n" + traceback.format_exc())
# handle canceling a download
elif _type == "nomadnet.download.cancel":
# get data from websocket client
- download_id = data["download_id"]
+ download_id = data.get("download_id")
+ if download_id is None:
+ return
# cancel the download
if download_id in self.active_downloads:
@@ -8152,8 +8484,11 @@ class ReticulumMeshChat:
# handle getting page archives
elif _type == "nomadnet.page.archives.get":
- destination_hash = data["destination_hash"]
- page_path = data["page_path"]
+ destination_hash = data.get("destination_hash")
+ page_path = data.get("page_path")
+
+ if not destination_hash or not page_path:
+ return
# Try relative path first
archives = self.get_archived_page_versions(destination_hash, page_path)
@@ -8173,13 +8508,13 @@ class ReticulumMeshChat:
"page_path": page_path,
"archives": [
{
- "id": archive.id,
- "hash": archive.hash,
- "destination_hash": archive.destination_hash,
- "page_path": archive.page_path,
- "created_at": archive.created_at.isoformat()
- if hasattr(archive.created_at, "isoformat")
- else str(archive.created_at),
+ "id": archive["id"],
+ "hash": archive["hash"],
+ "destination_hash": archive["destination_hash"],
+ "page_path": archive["page_path"],
+ "created_at": archive["created_at"].isoformat()
+ if hasattr(archive["created_at"], "isoformat")
+ else str(archive["created_at"]),
}
for archive in archives
],
@@ -8190,7 +8525,10 @@ class ReticulumMeshChat:
# handle loading a specific archived page version
elif _type == "nomadnet.page.archive.load":
- archive_id = data["archive_id"]
+ archive_id = data.get("archive_id")
+ if archive_id is None:
+ return
+
archive = self.database.misc.get_archived_page_by_id(archive_id)
if archive:
@@ -8221,9 +8559,13 @@ class ReticulumMeshChat:
# handle manual page archiving
elif _type == "nomadnet.page.archive.add":
- destination_hash = data["destination_hash"]
- page_path = data["page_path"]
- content = data["content"]
+ destination_hash = data.get("destination_hash")
+ page_path = data.get("page_path")
+ content = data.get("content")
+
+ if not destination_hash or not page_path or not content:
+ return
+
self.archive_page(destination_hash, page_path, content, is_manual=True)
# notify client that page was archived
@@ -8242,8 +8584,15 @@ class ReticulumMeshChat:
# handle downloading a file from a nomadnet node
elif _type == "nomadnet.file.download":
# get data from websocket client
- destination_hash = data["nomadnet_file_download"]["destination_hash"]
- file_path = data["nomadnet_file_download"]["file_path"]
+ download_data = data.get("nomadnet_file_download")
+ if not download_data:
+ return
+
+ destination_hash = download_data.get("destination_hash")
+ file_path = download_data.get("file_path")
+
+ if not destination_hash or not file_path:
+ return
# convert destination hash to bytes
destination_hash = bytes.fromhex(destination_hash)
@@ -8363,9 +8712,16 @@ class ReticulumMeshChat:
# handle downloading a page from a nomadnet node
elif _type == "nomadnet.page.download":
# get data from websocket client
- destination_hash = data["nomadnet_page_download"]["destination_hash"]
- page_path = data["nomadnet_page_download"]["page_path"]
- field_data = data["nomadnet_page_download"]["field_data"]
+ page_download_data = data.get("nomadnet_page_download")
+ if not page_download_data:
+ return
+
+ destination_hash = page_download_data.get("destination_hash")
+ page_path = page_download_data.get("page_path")
+ field_data = page_download_data.get("field_data")
+
+ if not destination_hash or not page_path:
+ return
# generate download id
self.download_id_counter += 1
@@ -8575,7 +8931,10 @@ class ReticulumMeshChat:
# handle ingesting an lxmf uri (paper message)
elif _type == "lxm.ingest_uri":
- uri = data["uri"]
+ uri = data.get("uri")
+ if not uri:
+ return
+
local_delivery_signal = "local_delivery_occurred"
duplicate_signal = "duplicate_lxm"
@@ -8634,10 +8993,13 @@ class ReticulumMeshChat:
# handle generating a paper message uri
elif _type == "lxm.generate_paper_uri":
- destination_hash = data["destination_hash"]
- content = data["content"]
+ destination_hash = data.get("destination_hash")
+ content = data.get("content")
title = data.get("title", "")
+ if not destination_hash or not content:
+ return
+
try:
destination_hash_bytes = bytes.fromhex(destination_hash)
destination_identity = RNS.Identity.recall(destination_hash_bytes)
@@ -8970,7 +9332,7 @@ class ReticulumMeshChat:
if identity is not None:
try:
return RNS.Destination.hash(identity, "lxst", "telephony").hex()
- except Exception: # noqa: S110
+ except Exception:
return None
return None
@@ -9082,8 +9444,16 @@ class ReticulumMeshChat:
icon_hashes_to_check.append(lxmf_destination_hash)
icon_hashes_to_check.append(announce["destination_hash"])
+ # ensure we don't return the user's own icon for peers
+ local_hash = None
+ if self.current_context and self.current_context.local_lxmf_destination:
+ local_hash = self.current_context.local_lxmf_destination.hexhash
+
db_lxmf_user_icon = None
for icon_hash in icon_hashes_to_check:
+ # skip if this is the user's own hash - don't return user's icon for peers
+ if local_hash and icon_hash == local_hash:
+ continue
db_lxmf_user_icon = self.database.misc.get_user_icon(icon_hash)
if db_lxmf_user_icon:
break
@@ -9142,6 +9512,15 @@ class ReticulumMeshChat:
if not ctx:
return
+ # ensure we're not storing the user's own icon with a peer's hash
+ # only store icons for remote peers, not for the local user
+ if (
+ ctx.local_lxmf_destination
+ and destination_hash == ctx.local_lxmf_destination.hexhash
+ ):
+ print(f"skipping icon update for local user's own hash: {destination_hash}")
+ return
+
# log
print(
f"updating lxmf user icon for {destination_hash} to icon_name={icon_name}, foreground_colour={foreground_colour}, background_colour={background_colour}",
@@ -9483,7 +9862,9 @@ class ReticulumMeshChat:
# sends a previously failed message via a propagation node
def send_failed_message_via_propagation_node(
- self, lxmf_message: LXMF.LXMessage, context=None
+ self,
+ lxmf_message: LXMF.LXMessage,
+ context=None,
):
ctx = context or self.current_context
if not ctx:
@@ -9681,7 +10062,7 @@ class ReticulumMeshChat:
current_icon_hash = self.get_current_icon_hash()
if current_icon_hash is not None:
last_sent_icon_hash = self.database.misc.get_last_sent_icon_hash(
- destination_hash
+ destination_hash,
)
if last_sent_icon_hash != current_icon_hash:
@@ -9696,24 +10077,25 @@ class ReticulumMeshChat:
lxmf_message.fields[LXMF.FIELD_ICON_APPEARANCE] = [
lxmf_user_icon_name,
ColourUtils.hex_colour_to_byte_array(
- lxmf_user_icon_foreground_colour
+ lxmf_user_icon_foreground_colour,
),
ColourUtils.hex_colour_to_byte_array(
- lxmf_user_icon_background_colour
+ lxmf_user_icon_background_colour,
),
]
# update last sent icon hash for this destination
ctx.database.misc.update_last_sent_icon_hash(
- destination_hash, current_icon_hash
+ destination_hash,
+ current_icon_hash,
)
# register delivery callbacks
lxmf_message.register_delivery_callback(
- lambda msg: self.on_lxmf_sending_state_updated(msg, context=ctx)
+ lambda msg: self.on_lxmf_sending_state_updated(msg, context=ctx),
)
lxmf_message.register_failed_callback(
- lambda msg: self.on_lxmf_sending_failed(msg, context=ctx)
+ lambda msg: self.on_lxmf_sending_failed(msg, context=ctx),
)
# determine which router to use
@@ -9752,7 +10134,7 @@ class ReticulumMeshChat:
# which results in the next message not showing up until the first message is finished
if not no_display:
AsyncUtils.run_async(
- self.handle_lxmf_message_progress(lxmf_message, context=ctx)
+ self.handle_lxmf_message_progress(lxmf_message, context=ctx),
)
return lxmf_message
@@ -9885,11 +10267,12 @@ class ReticulumMeshChat:
f" ({display_name})"
if (
display_name := parse_lxmf_display_name(
- base64.b64encode(app_data).decode() if app_data else None, None
+ base64.b64encode(app_data).decode() if app_data else None,
+ None,
)
)
else ""
- )
+ ),
)
# track announce timestamp
@@ -9993,7 +10376,8 @@ class ReticulumMeshChat:
if ctx.config.auto_resend_failed_messages_when_announce_received.get():
AsyncUtils.run_async(
self.resend_failed_messages_for_destination(
- destination_hash.hex(), context=ctx
+ destination_hash.hex(),
+ context=ctx,
),
)
@@ -10051,7 +10435,9 @@ class ReticulumMeshChat:
# resends all messages that previously failed to send to the provided destination hash
async def resend_failed_messages_for_destination(
- self, destination_hash: str, context=None
+ self,
+ destination_hash: str,
+ context=None,
):
ctx = context or self.current_context
if not ctx:
@@ -10559,7 +10945,9 @@ def main():
if not os.path.exists(snapshot_path):
# Try in storage_dir/snapshots
potential_path = os.path.join(
- reticulum_meshchat.storage_dir, "snapshots", snapshot_path
+ reticulum_meshchat.storage_dir,
+ "snapshots",
+ snapshot_path,
)
if os.path.exists(potential_path):
snapshot_path = potential_path
@@ -10570,7 +10958,7 @@ def main():
print(f"Restoring database from snapshot: {snapshot_path}")
result = reticulum_meshchat.restore_database(snapshot_path)
print(
- f"Snapshot restoration complete. Integrity check: {result['integrity_check']}"
+ f"Snapshot restoration complete. Integrity check: {result['integrity_check']}",
)
else:
print(f"Error: Snapshot not found at {snapshot_path}")
diff --git a/meshchatx/src/backend/announce_manager.py b/meshchatx/src/backend/announce_manager.py
index 48fa2011..173ccada 100644
--- a/meshchatx/src/backend/announce_manager.py
+++ b/meshchatx/src/backend/announce_manager.py
@@ -52,28 +52,37 @@ class AnnounceManager:
limit=None,
offset=0,
):
- sql = "SELECT * FROM announces WHERE 1=1"
+ sql = """
+ SELECT a.*, c.custom_image as contact_image
+ FROM announces a
+ LEFT JOIN contacts c ON (
+ a.identity_hash = c.remote_identity_hash OR
+ a.destination_hash = c.lxmf_address OR
+ a.destination_hash = c.lxst_address
+ )
+ WHERE 1=1
+ """
params = []
if aspect:
- sql += " AND aspect = ?"
+ sql += " AND a.aspect = ?"
params.append(aspect)
if identity_hash:
- sql += " AND identity_hash = ?"
+ sql += " AND a.identity_hash = ?"
params.append(identity_hash)
if destination_hash:
- sql += " AND destination_hash = ?"
+ sql += " AND a.destination_hash = ?"
params.append(destination_hash)
if query:
like_term = f"%{query}%"
- sql += " AND (destination_hash LIKE ? OR identity_hash LIKE ?)"
+ sql += " AND (a.destination_hash LIKE ? OR a.identity_hash LIKE ?)"
params.extend([like_term, like_term])
if blocked_identity_hashes:
placeholders = ", ".join(["?"] * len(blocked_identity_hashes))
- sql += f" AND identity_hash NOT IN ({placeholders})"
+ sql += f" AND a.identity_hash NOT IN ({placeholders})"
params.extend(blocked_identity_hashes)
- sql += " ORDER BY updated_at DESC"
+ sql += " ORDER BY a.updated_at DESC"
if limit is not None:
sql += " LIMIT ? OFFSET ?"
diff --git a/meshchatx/src/backend/async_utils.py b/meshchatx/src/backend/async_utils.py
index 13de95fd..fc053dc1 100644
--- a/meshchatx/src/backend/async_utils.py
+++ b/meshchatx/src/backend/async_utils.py
@@ -9,8 +9,7 @@ class AsyncUtils:
@staticmethod
def apply_asyncio_313_patch():
- """
- Apply a patch for asyncio on Python 3.13 to avoid a bug in sendfile with SSL.
+ """Apply a patch for asyncio on Python 3.13 to avoid a bug in sendfile with SSL.
See: https://github.com/python/cpython/issues/124448
And: https://github.com/aio-libs/aiohttp/issues/8863
"""
@@ -23,14 +22,25 @@ class AsyncUtils:
original_sendfile = asyncio.base_events.BaseEventLoop.sendfile
async def patched_sendfile(
- self, transport, file, offset=0, count=None, *, fallback=True
+ self,
+ transport,
+ file,
+ offset=0,
+ count=None,
+ *,
+ fallback=True,
):
if transport.get_extra_info("sslcontext"):
raise NotImplementedError(
- "sendfile is broken on SSL transports in Python 3.13"
+ "sendfile is broken on SSL transports in Python 3.13",
)
return await original_sendfile(
- self, transport, file, offset, count, fallback=fallback
+ self,
+ transport,
+ file,
+ offset,
+ count,
+ fallback=fallback,
)
asyncio.base_events.BaseEventLoop.sendfile = patched_sendfile
diff --git a/meshchatx/src/backend/bot_handler.py b/meshchatx/src/backend/bot_handler.py
new file mode 100644
index 00000000..576fef9f
--- /dev/null
+++ b/meshchatx/src/backend/bot_handler.py
@@ -0,0 +1,279 @@
+import json
+import logging
+import os
+import shutil
+import subprocess
+import sys
+import time
+import uuid
+
+import RNS
+
+logger = logging.getLogger("meshchatx.bots")
+
+
+class BotHandler:
+ def __init__(self, identity_path, config_manager=None):
+ self.identity_path = os.path.abspath(identity_path)
+ self.config_manager = config_manager
+ self.bots_dir = os.path.join(self.identity_path, "bots")
+ os.makedirs(self.bots_dir, exist_ok=True)
+ self.running_bots = {}
+ self.state_file = os.path.join(self.bots_dir, "bots_state.json")
+ self.bots_state: list[dict] = []
+ self._load_state()
+ self.runner_path = os.path.join(
+ os.path.dirname(__file__),
+ "bot_process.py",
+ )
+
+ def _load_state(self):
+ try:
+ with open(self.state_file, encoding="utf-8") as f:
+ self.bots_state = json.load(f)
+ # Ensure all storage paths are absolute
+ for entry in self.bots_state:
+ if "storage_dir" in entry:
+ entry["storage_dir"] = os.path.abspath(entry["storage_dir"])
+ except FileNotFoundError:
+ self.bots_state = []
+ except Exception:
+ self.bots_state = []
+
+ def _save_state(self):
+ try:
+ with open(self.state_file, "w", encoding="utf-8") as f:
+ json.dump(self.bots_state, f, indent=2)
+ except Exception:
+ pass
+
+ def get_available_templates(self):
+ return [
+ {
+ "id": "echo",
+ "name": "Echo Bot",
+ "description": "Repeats any message it receives.",
+ },
+ {
+ "id": "note",
+ "name": "Note Bot",
+ "description": "Store and retrieve notes using JSON storage.",
+ },
+ {
+ "id": "reminder",
+ "name": "Reminder Bot",
+ "description": "Set and receive reminders using SQLite storage.",
+ },
+ ]
+
+ def restore_enabled_bots(self):
+ for entry in list(self.bots_state):
+ if entry.get("enabled"):
+ try:
+ self.start_bot(
+ template_id=entry["template_id"],
+ name=entry["name"],
+ bot_id=entry["id"],
+ storage_dir=entry["storage_dir"],
+ )
+ except Exception as exc:
+ logger.warning("Failed to restore bot %s: %s", entry.get("id"), exc)
+
+ def get_status(self):
+ bots = []
+ for bot_id, bot_info in self.running_bots.items():
+ instance = bot_info["instance"]
+ bots.append(
+ {
+ "id": bot_id,
+ "template": bot_info["template"],
+ "name": instance.bot.config.name
+ if instance and instance.bot
+ else "Unknown",
+ "address": RNS.prettyhexrep(instance.bot.local.hash)
+ if instance and instance.bot and instance.bot.local
+ else "Unknown",
+ "full_address": RNS.hexrep(instance.bot.local.hash, delimit=False)
+ if instance and instance.bot and instance.bot.local
+ else None,
+ },
+ )
+
+ return {
+ "has_lxmfy": True,
+ "detection_error": None,
+ "running_bots": bots,
+ "bots": self.bots_state,
+ }
+
+ def start_bot(self, template_id, name=None, bot_id=None, storage_dir=None):
+ # Reuse existing entry or create new
+ entry = None
+ if bot_id:
+ for e in self.bots_state:
+ if e.get("id") == bot_id:
+ entry = e
+ break
+ if entry is None:
+ bot_id = bot_id or uuid.uuid4().hex
+ bot_storage_dir = storage_dir or os.path.join(self.bots_dir, bot_id)
+ bot_storage_dir = os.path.abspath(bot_storage_dir)
+ entry = {
+ "id": bot_id,
+ "template_id": template_id,
+ "name": name or f"{template_id.title()} Bot",
+ "storage_dir": bot_storage_dir,
+ "enabled": True,
+ "pid": None,
+ }
+ self.bots_state.append(entry)
+ else:
+ bot_storage_dir = entry["storage_dir"]
+ entry["template_id"] = template_id
+ entry["name"] = name or entry.get("name") or f"{template_id.title()} Bot"
+ entry["enabled"] = True
+
+ os.makedirs(bot_storage_dir, exist_ok=True)
+
+ cmd = [
+ sys.executable,
+ self.runner_path,
+ "--template",
+ template_id,
+ "--name",
+ entry["name"],
+ "--storage",
+ bot_storage_dir,
+ ]
+
+ proc = subprocess.Popen(cmd, cwd=bot_storage_dir) # noqa: S603
+ entry["pid"] = proc.pid
+ self._save_state()
+
+ self.running_bots[bot_id] = {
+ "instance": None,
+ "thread": None,
+ "stop_event": None,
+ "template": template_id,
+ "pid": proc.pid,
+ }
+ logger.info(f"Started bot {bot_id} (template: {template_id}) pid={proc.pid}")
+ return bot_id
+
+ def stop_bot(self, bot_id):
+ entry = None
+ for e in self.bots_state:
+ if e.get("id") == bot_id:
+ entry = e
+ break
+ if entry is None:
+ return False
+
+ pid = entry.get("pid")
+ if pid:
+ try:
+ if sys.platform.startswith("win"):
+ subprocess.run(
+ ["taskkill", "/PID", str(pid), "/T", "/F"],
+ check=False,
+ timeout=5,
+ )
+ else:
+ os.kill(pid, 15)
+ # brief wait
+ time.sleep(0.5)
+ # optional force kill if still alive
+ try:
+ os.kill(pid, 0)
+ os.kill(pid, 9)
+ except OSError:
+ pass
+ except Exception as exc:
+ logger.warning(
+ "Failed to terminate bot %s pid %s: %s",
+ bot_id,
+ pid,
+ exc,
+ )
+
+ entry["pid"] = None
+ entry["enabled"] = False
+ self._save_state()
+ if bot_id in self.running_bots:
+ del self.running_bots[bot_id]
+ logger.info("Stopped bot %s", bot_id)
+ return True
+
+ def restart_bot(self, bot_id):
+ entry = None
+ for e in self.bots_state:
+ if e.get("id") == bot_id:
+ entry = e
+ break
+ if entry is None:
+ raise ValueError(f"Unknown bot: {bot_id}")
+ self.stop_bot(bot_id)
+ return self.start_bot(
+ template_id=entry["template_id"],
+ name=entry["name"],
+ bot_id=bot_id,
+ storage_dir=entry["storage_dir"],
+ )
+
+ def delete_bot(self, bot_id):
+ # Stop it first
+ self.stop_bot(bot_id)
+
+ # Remove from state
+ entry = None
+ for i, e in enumerate(self.bots_state):
+ if e.get("id") == bot_id:
+ entry = e
+ del self.bots_state[i]
+ break
+
+ if entry:
+ # Delete storage dir
+ storage_dir = entry.get("storage_dir")
+ if storage_dir and os.path.exists(storage_dir):
+ try:
+ shutil.rmtree(storage_dir)
+ except Exception as exc:
+ logger.warning(
+ "Failed to delete storage dir for bot %s: %s", bot_id, exc
+ )
+
+ self._save_state()
+ logger.info("Deleted bot %s", bot_id)
+ return True
+ return False
+
+ def get_bot_identity_path(self, bot_id):
+ entry = None
+ for e in self.bots_state:
+ if e.get("id") == bot_id:
+ entry = e
+ break
+
+ if not entry:
+ return None
+
+ storage_dir = entry.get("storage_dir")
+ if not storage_dir:
+ return None
+
+ # LXMFy stores identity in the 'config' subdirectory by default
+ id_path = os.path.join(storage_dir, "config", "identity")
+ if os.path.exists(id_path):
+ return id_path
+
+ # Fallback to direct identity file if it was moved or configured differently
+ id_path_alt = os.path.join(storage_dir, "identity")
+ if os.path.exists(id_path_alt):
+ return id_path_alt
+
+ return None
+
+ def stop_all(self):
+ for bot_id in list(self.running_bots.keys()):
+ self.stop_bot(bot_id)
diff --git a/meshchatx/src/backend/bot_process.py b/meshchatx/src/backend/bot_process.py
new file mode 100644
index 00000000..91f53ed1
--- /dev/null
+++ b/meshchatx/src/backend/bot_process.py
@@ -0,0 +1,45 @@
+import argparse
+import os
+
+from meshchatx.src.backend.bot_templates import (
+ EchoBotTemplate,
+ NoteBotTemplate,
+ ReminderBotTemplate,
+)
+
+TEMPLATE_MAP = {
+ "echo": EchoBotTemplate,
+ "note": NoteBotTemplate,
+ "reminder": ReminderBotTemplate,
+}
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--template", required=True, choices=TEMPLATE_MAP.keys())
+ parser.add_argument("--name", required=True)
+ parser.add_argument("--storage", required=True)
+ args = parser.parse_args()
+
+ os.makedirs(args.storage, exist_ok=True)
+ os.chdir(args.storage)
+
+ BotCls = TEMPLATE_MAP[args.template]
+ # LXMFy hardcodes its config directory to os.path.join(os.getcwd(), 'config').
+ # By chdir'ing into args.storage, we ensure 'config' and data are kept within that folder.
+ bot_instance = BotCls(name=args.name, storage_path=args.storage, test_mode=False)
+
+ # Optional immediate announce for reachability
+ try:
+ if hasattr(bot_instance.bot, "announce_enabled"):
+ bot_instance.bot.announce_enabled = True
+ if hasattr(bot_instance.bot, "_announce"):
+ bot_instance.bot._announce()
+ except Exception:
+ pass
+
+ bot_instance.run()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/meshchatx/src/backend/bot_templates.py b/meshchatx/src/backend/bot_templates.py
new file mode 100644
index 00000000..5814f2d4
--- /dev/null
+++ b/meshchatx/src/backend/bot_templates.py
@@ -0,0 +1,265 @@
+import re
+import time
+from datetime import datetime, timedelta
+
+from lxmfy import IconAppearance, LXMFBot, pack_icon_appearance_field
+
+HAS_LXMFY = True
+
+
+class StoppableBot:
+ def __init__(self):
+ self._stop_event = None
+
+ def set_stop_event(self, stop_event):
+ self._stop_event = stop_event
+
+ def should_stop(self):
+ return self._stop_event and self._stop_event.is_set()
+
+
+class EchoBotTemplate(StoppableBot):
+ def __init__(self, name="Echo Bot", storage_path=None, test_mode=False):
+ super().__init__()
+
+ self.bot = LXMFBot(
+ name=name,
+ announce=600,
+ command_prefix="",
+ first_message_enabled=True,
+ test_mode=test_mode,
+ storage_path=storage_path,
+ )
+ self.setup_commands()
+ self.setup_message_handlers()
+
+ icon_data = IconAppearance(
+ icon_name="forum",
+ fg_color=b"\xad\xd8\xe6",
+ bg_color=b"\x3b\x59\x98",
+ )
+ self.icon_lxmf_field = pack_icon_appearance_field(icon_data)
+
+ def setup_message_handlers(self):
+ @self.bot.on_message()
+ def echo_non_command_messages(sender, message):
+ if self.should_stop():
+ return True
+ content = message.content.decode("utf-8").strip()
+ if not content:
+ return False
+
+ command_name = content.split()[0]
+ if command_name in self.bot.commands:
+ return False
+
+ self.bot.send(
+ sender,
+ content,
+ lxmf_fields=self.icon_lxmf_field,
+ )
+ return False
+
+ def setup_commands(self):
+ @self.bot.command(name="echo", description="Echo back your message")
+ def echo(ctx):
+ if self.should_stop():
+ return
+ if ctx.args:
+ ctx.reply(" ".join(ctx.args), lxmf_fields=self.icon_lxmf_field)
+ else:
+ ctx.reply("Usage: echo <message>", lxmf_fields=self.icon_lxmf_field)
+
+ @self.bot.on_first_message()
+ def welcome(sender, message):
+ if self.should_stop():
+ return True
+ content = message.content.decode("utf-8").strip()
+ self.bot.send(
+ sender,
+ f"Hi! I'm an echo bot, You said: {content}\n\n"
+ "Try: echo <message> to make me repeat things!",
+ lxmf_fields=self.icon_lxmf_field,
+ )
+ return True
+
+ def run(self):
+ self.bot.scheduler.start()
+ try:
+ while not self.should_stop():
+ for _ in range(self.bot.queue.qsize()):
+ lxm = self.bot.queue.get()
+ if self.bot.router:
+ self.bot.router.handle_outbound(lxm)
+ time.sleep(1)
+ finally:
+ self.bot.cleanup()
+
+
+class NoteBotTemplate(StoppableBot):
+ def __init__(self, name="Note Bot", storage_path=None, test_mode=False):
+ super().__init__()
+
+ self.bot = LXMFBot(
+ name=name,
+ announce=600,
+ command_prefix="/",
+ storage_type="json",
+ storage_path=storage_path or "data/notes",
+ test_mode=test_mode,
+ )
+ self.setup_commands()
+
+ def setup_commands(self):
+ @self.bot.command(name="note", description="Save a note")
+ def save_note(ctx):
+ if self.should_stop():
+ return
+ if not ctx.args:
+ ctx.reply("Usage: /note <your note>")
+ return
+
+ note = {
+ "text": " ".join(ctx.args),
+ "timestamp": datetime.now().isoformat(),
+ "tags": [w[1:] for w in ctx.args if w.startswith("#")],
+ }
+
+ notes = self.bot.storage.get(f"notes:{ctx.sender}", [])
+ notes.append(note)
+ self.bot.storage.set(f"notes:{ctx.sender}", notes)
+ ctx.reply("Note saved!")
+
+ @self.bot.command(name="notes", description="List your notes")
+ def list_notes(ctx):
+ if self.should_stop():
+ return
+ notes = self.bot.storage.get(f"notes:{ctx.sender}", [])
+ if not notes:
+ ctx.reply("You haven't saved any notes yet!")
+ return
+
+ if not ctx.args:
+ response = "Your Notes:\n"
+ for i, note in enumerate(notes[-10:], 1):
+ tags = (
+ " ".join(f"#{tag}" for tag in note["tags"])
+ if note["tags"]
+ else ""
+ )
+ response += f"{i}. {note['text']} {tags}\n"
+ if len(notes) > 10:
+ response += f"\nShowing last 10 of {len(notes)} notes. Use /notes all to see all."
+ ctx.reply(response)
+ elif ctx.args[0] == "all":
+ response = "All Your Notes:\n"
+ for i, note in enumerate(notes, 1):
+ tags = (
+ " ".join(f"#{tag}" for tag in note["tags"])
+ if note["tags"]
+ else ""
+ )
+ response += f"{i}. {note['text']} {tags}\n"
+ ctx.reply(response)
+
+ def run(self):
+ self.bot.scheduler.start()
+ try:
+ while not self.should_stop():
+ for _ in range(self.bot.queue.qsize()):
+ lxm = self.bot.queue.get()
+ if self.bot.router:
+ self.bot.router.handle_outbound(lxm)
+ time.sleep(1)
+ finally:
+ self.bot.cleanup()
+
+
+class ReminderBotTemplate(StoppableBot):
+ def __init__(self, name="Reminder Bot", storage_path=None, test_mode=False):
+ super().__init__()
+
+ self.bot = LXMFBot(
+ name=name,
+ announce=600,
+ command_prefix="/",
+ storage_type="sqlite",
+ storage_path=storage_path or "data/reminders.db",
+ test_mode=test_mode,
+ )
+ self.setup_commands()
+ self.bot.scheduler.add_task(
+ "check_reminders",
+ self._check_reminders,
+ "*/1 * * * *",
+ )
+
+ def setup_commands(self):
+ @self.bot.command(name="remind", description="Set a reminder")
+ def remind(ctx):
+ if self.should_stop():
+ return
+ if not ctx.args or len(ctx.args) < 2:
+ ctx.reply(
+ "Usage: /remind <time> <message>\nExample: /remind 1h30m Buy groceries",
+ )
+ return
+
+ time_str = ctx.args[0].lower()
+ message = " ".join(ctx.args[1:])
+
+ total_minutes = 0
+ time_parts = re.findall(r"(\d+)([dhm])", time_str)
+
+ for value, unit in time_parts:
+ if unit == "d":
+ total_minutes += int(value) * 24 * 60
+ elif unit == "h":
+ total_minutes += int(value) * 60
+ elif unit == "m":
+ total_minutes += int(value)
+
+ if total_minutes == 0:
+ ctx.reply("Invalid time format. Use combinations of d, h, m")
+ return
+
+ remind_time = datetime.now() + timedelta(minutes=total_minutes)
+ reminder = {
+ "user": ctx.sender,
+ "message": message,
+ "time": remind_time.timestamp(),
+ "created": time.time(),
+ }
+
+ reminders = self.bot.storage.get("reminders", [])
+ reminders.append(reminder)
+ self.bot.storage.set("reminders", reminders)
+ ctx.reply(
+ f"I'll remind you about '{message}' at {remind_time.strftime('%Y-%m-%d %H:%M:%S')}",
+ )
+
+ def _check_reminders(self):
+ if self.should_stop():
+ return
+ reminders = self.bot.storage.get("reminders", [])
+ current_time = time.time()
+ due_reminders = [r for r in reminders if r["time"] <= current_time]
+ remaining = [r for r in reminders if r["time"] > current_time]
+
+ for reminder in due_reminders:
+ self.bot.send(reminder["user"], f"Reminder: {reminder['message']}")
+
+ if due_reminders:
+ self.bot.storage.set("reminders", remaining)
+
+ def run(self):
+ self.bot.scheduler.start()
+ try:
+ while not self.should_stop():
+ for _ in range(self.bot.queue.qsize()):
+ lxm = self.bot.queue.get()
+ if self.bot.router:
+ self.bot.router.handle_outbound(lxm)
+ time.sleep(1)
+ finally:
+ self.bot.cleanup()
diff --git a/meshchatx/src/backend/community_interfaces.py b/meshchatx/src/backend/community_interfaces.py
index 59d7b661..e2a87757 100644
--- a/meshchatx/src/backend/community_interfaces.py
+++ b/meshchatx/src/backend/community_interfaces.py
@@ -1,6 +1,6 @@
import asyncio
import time
-from typing import List, Dict, Any
+from typing import Any
class CommunityInterfacesManager:
@@ -67,7 +67,8 @@ class CommunityInterfacesManager:
# but that requires Reticulum to be running with a configured interface to that target.
# For "suggested" interfaces, we just check if they are reachable.
reader, writer = await asyncio.wait_for(
- asyncio.open_connection(host, port), timeout=3.0
+ asyncio.open_connection(host, port),
+ timeout=3.0,
)
writer.close()
await writer.wait_closed()
@@ -90,7 +91,7 @@ class CommunityInterfacesManager:
}
self.last_check = time.time()
- async def get_interfaces(self) -> List[Dict[str, Any]]:
+ async def get_interfaces(self) -> list[dict[str, Any]]:
# If cache is old or empty, update it
if time.time() - self.last_check > self.check_interval or not self.status_cache:
# We don't want to block the request, so we could do this in background
@@ -100,14 +101,15 @@ class CommunityInterfacesManager:
results = []
for iface in self.interfaces:
status = self.status_cache.get(
- iface["name"], {"online": False, "last_check": 0}
+ iface["name"],
+ {"online": False, "last_check": 0},
)
results.append(
{
**iface,
"online": status["online"],
"last_check": status["last_check"],
- }
+ },
)
# Sort so online ones are first
diff --git a/meshchatx/src/backend/config_manager.py b/meshchatx/src/backend/config_manager.py
index a623e57b..e8ab0939 100644
--- a/meshchatx/src/backend/config_manager.py
+++ b/meshchatx/src/backend/config_manager.py
@@ -60,6 +60,8 @@ class ConfigManager:
"lxmf_preferred_propagation_node_last_synced_at",
None,
)
+ self.lxmf_address_hash = self.StringConfig(self, "lxmf_address_hash", None)
+ self.lxst_address_hash = self.StringConfig(self, "lxst_address_hash", None)
self.lxmf_local_propagation_node_enabled = self.BoolConfig(
self,
"lxmf_local_propagation_node_enabled",
@@ -119,7 +121,9 @@ class ConfigManager:
False,
)
self.gitea_base_url = self.StringConfig(
- self, "gitea_base_url", "https://git.quad4.io"
+ self,
+ "gitea_base_url",
+ "https://git.quad4.io",
)
self.docs_download_urls = self.StringConfig(
self,
@@ -159,7 +163,9 @@ class ConfigManager:
self.voicemail_tts_speed = self.IntConfig(self, "voicemail_tts_speed", 130)
self.voicemail_tts_pitch = self.IntConfig(self, "voicemail_tts_pitch", 45)
self.voicemail_tts_voice = self.StringConfig(
- self, "voicemail_tts_voice", "en-us+f3"
+ self,
+ "voicemail_tts_voice",
+ "en-us+f3",
)
self.voicemail_tts_word_gap = self.IntConfig(self, "voicemail_tts_word_gap", 5)
diff --git a/meshchatx/src/backend/database/__init__.py b/meshchatx/src/backend/database/__init__.py
index 33e7d60c..29d2ffce 100644
--- a/meshchatx/src/backend/database/__init__.py
+++ b/meshchatx/src/backend/database/__init__.py
@@ -130,7 +130,7 @@ class Database:
self._checkpoint_wal()
except Exception as e:
print(
- f"Warning: WAL checkpoint during vacuum failed (non-critical): {e}"
+ f"Warning: WAL checkpoint during vacuum failed (non-critical): {e}",
)
self.execute_sql("VACUUM")
@@ -226,7 +226,7 @@ class Database:
os.makedirs(snapshot_dir, exist_ok=True)
# Ensure name is safe for filesystem
safe_name = "".join(
- [c for c in name if c.isalnum() or c in (" ", ".", "-", "_")]
+ [c for c in name if c.isalnum() or c in (" ", ".", "-", "_")],
).strip()
if not safe_name:
safe_name = "unnamed_snapshot"
@@ -251,9 +251,10 @@ class Database:
"path": full_path,
"size": stats.st_size,
"created_at": datetime.fromtimestamp(
- stats.st_mtime, UTC
+ stats.st_mtime,
+ UTC,
).isoformat(),
- }
+ },
)
return sorted(snapshots, key=lambda x: x["created_at"], reverse=True)
diff --git a/meshchatx/src/backend/database/contacts.py b/meshchatx/src/backend/database/contacts.py
index b3b81a25..a1843881 100644
--- a/meshchatx/src/backend/database/contacts.py
+++ b/meshchatx/src/backend/database/contacts.py
@@ -6,19 +6,34 @@ class ContactsDAO:
self.provider = provider
def add_contact(
- self, name, remote_identity_hash, preferred_ringtone_id=None, custom_image=None
+ self,
+ name,
+ remote_identity_hash,
+ lxmf_address=None,
+ lxst_address=None,
+ preferred_ringtone_id=None,
+ custom_image=None,
):
self.provider.execute(
"""
- INSERT INTO contacts (name, remote_identity_hash, preferred_ringtone_id, custom_image)
- VALUES (?, ?, ?, ?)
+ INSERT INTO contacts (name, remote_identity_hash, lxmf_address, lxst_address, preferred_ringtone_id, custom_image)
+ VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(remote_identity_hash) DO UPDATE SET
name = EXCLUDED.name,
+ lxmf_address = COALESCE(EXCLUDED.lxmf_address, contacts.lxmf_address),
+ lxst_address = COALESCE(EXCLUDED.lxst_address, contacts.lxst_address),
preferred_ringtone_id = EXCLUDED.preferred_ringtone_id,
custom_image = EXCLUDED.custom_image,
updated_at = CURRENT_TIMESTAMP
""",
- (name, remote_identity_hash, preferred_ringtone_id, custom_image),
+ (
+ name,
+ remote_identity_hash,
+ lxmf_address,
+ lxst_address,
+ preferred_ringtone_id,
+ custom_image,
+ ),
)
def get_contacts(self, search=None, limit=100, offset=0):
@@ -26,10 +41,17 @@ class ContactsDAO:
return self.provider.fetchall(
"""
SELECT * FROM contacts
- WHERE name LIKE ? OR remote_identity_hash LIKE ?
+ WHERE name LIKE ? OR remote_identity_hash LIKE ? OR lxmf_address LIKE ? OR lxst_address LIKE ?
ORDER BY name ASC LIMIT ? OFFSET ?
""",
- (f"%{search}%", f"%{search}%", limit, offset),
+ (
+ f"%{search}%",
+ f"%{search}%",
+ f"%{search}%",
+ f"%{search}%",
+ limit,
+ offset,
+ ),
)
return self.provider.fetchall(
"SELECT * FROM contacts ORDER BY name ASC LIMIT ? OFFSET ?",
@@ -47,6 +69,8 @@ class ContactsDAO:
contact_id,
name=None,
remote_identity_hash=None,
+ lxmf_address=None,
+ lxst_address=None,
preferred_ringtone_id=None,
custom_image=None,
clear_image=False,
@@ -60,6 +84,12 @@ class ContactsDAO:
if remote_identity_hash is not None:
updates.append("remote_identity_hash = ?")
params.append(remote_identity_hash)
+ if lxmf_address is not None:
+ updates.append("lxmf_address = ?")
+ params.append(lxmf_address)
+ if lxst_address is not None:
+ updates.append("lxst_address = ?")
+ params.append(lxst_address)
if preferred_ringtone_id is not None:
updates.append("preferred_ringtone_id = ?")
params.append(preferred_ringtone_id)
@@ -82,6 +112,6 @@ class ContactsDAO:
def get_contact_by_identity_hash(self, remote_identity_hash):
return self.provider.fetchone(
- "SELECT * FROM contacts WHERE remote_identity_hash = ?",
- (remote_identity_hash,),
+ "SELECT * FROM contacts WHERE remote_identity_hash = ? OR lxmf_address = ? OR lxst_address = ?",
+ (remote_identity_hash, remote_identity_hash, remote_identity_hash),
)
diff --git a/meshchatx/src/backend/database/debug_logs.py b/meshchatx/src/backend/database/debug_logs.py
index 8f7475f1..9ee243a1 100644
--- a/meshchatx/src/backend/database/debug_logs.py
+++ b/meshchatx/src/backend/database/debug_logs.py
@@ -1,4 +1,5 @@
from datetime import UTC, datetime
+
from .provider import DatabaseProvider
@@ -24,7 +25,13 @@ class DebugLogsDAO:
)
def get_logs(
- self, limit=100, offset=0, search=None, level=None, module=None, is_anomaly=None
+ self,
+ limit=100,
+ offset=0,
+ search=None,
+ level=None,
+ module=None,
+ is_anomaly=None,
):
sql = "SELECT * FROM debug_logs WHERE 1=1"
params = []
@@ -83,7 +90,8 @@ class DebugLogsDAO:
if row:
cutoff_ts = row["timestamp"]
self.provider.execute(
- "DELETE FROM debug_logs WHERE timestamp < ?", (cutoff_ts,)
+ "DELETE FROM debug_logs WHERE timestamp < ?",
+ (cutoff_ts,),
)
def get_anomalies(self, limit=50):
diff --git a/meshchatx/src/backend/database/map_drawings.py b/meshchatx/src/backend/database/map_drawings.py
index 6bbbfd7c..f195978a 100644
--- a/meshchatx/src/backend/database/map_drawings.py
+++ b/meshchatx/src/backend/database/map_drawings.py
@@ -1,4 +1,5 @@
from datetime import UTC, datetime
+
from .provider import DatabaseProvider
diff --git a/meshchatx/src/backend/database/provider.py b/meshchatx/src/backend/database/provider.py
index 532a2c8e..e60e2943 100644
--- a/meshchatx/src/backend/database/provider.py
+++ b/meshchatx/src/backend/database/provider.py
@@ -69,13 +69,12 @@ class DatabaseProvider:
self.connection.commit()
elif commit is False:
pass
- else:
- # Default behavior: if we're in a manual transaction, don't commit automatically
- if not self.connection.in_transaction:
- # In autocommit mode, non-DML statements don't start transactions.
- # DML statements might if they are part of a BEGIN block.
- # Actually, in isolation_level=None, NOTHING starts a transaction unless we say BEGIN.
- pass
+ # Default behavior: if we're in a manual transaction, don't commit automatically
+ elif not self.connection.in_transaction:
+ # In autocommit mode, non-DML statements don't start transactions.
+ # DML statements might if they are part of a BEGIN block.
+ # Actually, in isolation_level=None, NOTHING starts a transaction unless we say BEGIN.
+ pass
return cursor
def begin(self):
diff --git a/meshchatx/src/backend/database/schema.py b/meshchatx/src/backend/database/schema.py
index e0cd6f90..49f8b86f 100644
--- a/meshchatx/src/backend/database/schema.py
+++ b/meshchatx/src/backend/database/schema.py
@@ -2,7 +2,7 @@ from .provider import DatabaseProvider
class DatabaseSchema:
- LATEST_VERSION = 34
+ LATEST_VERSION = 35
def __init__(self, provider: DatabaseProvider):
self.provider = provider
@@ -63,21 +63,20 @@ class DatabaseSchema:
# Use the connection directly to avoid any middle-ware issues
res = self._safe_execute(
- f"ALTER TABLE {table_name} ADD COLUMN {column_name} {stmt_type}"
+ f"ALTER TABLE {table_name} ADD COLUMN {column_name} {stmt_type}",
)
return res is not None
except Exception as e:
# Log but don't crash, we might be able to continue
print(
- f"Unexpected error adding column {column_name} to {table_name}: {e}"
+ f"Unexpected error adding column {column_name} to {table_name}: {e}",
)
return False
return True
return True
def _sync_table_columns(self, table_name, create_sql):
- """
- Parses a CREATE TABLE statement and ensures all columns exist in the actual table.
+ """Parses a CREATE TABLE statement and ensures all columns exist in the actual table.
This is a robust way to handle legacy tables that are missing columns.
"""
# Find the first '(' and the last ')'
@@ -111,7 +110,7 @@ class DatabaseSchema:
definition = definition.strip()
# Skip table-level constraints
if not definition or definition.upper().startswith(
- ("PRIMARY KEY", "FOREIGN KEY", "UNIQUE", "CHECK")
+ ("PRIMARY KEY", "FOREIGN KEY", "UNIQUE", "CHECK"),
):
continue
@@ -365,6 +364,8 @@ class DatabaseSchema:
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
remote_identity_hash TEXT UNIQUE,
+ lxmf_address TEXT,
+ lxst_address TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
@@ -923,6 +924,15 @@ class DatabaseSchema:
"ALTER TABLE crawl_tasks ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP",
)
+ if current_version < 35:
+ # Add lxmf_address and lxst_address to contacts
+ self._safe_execute(
+ "ALTER TABLE contacts ADD COLUMN lxmf_address TEXT DEFAULT NULL",
+ )
+ self._safe_execute(
+ "ALTER TABLE contacts ADD COLUMN lxst_address TEXT DEFAULT NULL",
+ )
+
# Update version in config
self._safe_execute(
"""
diff --git a/meshchatx/src/backend/docs_manager.py b/meshchatx/src/backend/docs_manager.py
index 954f91a3..64c4335b 100644
--- a/meshchatx/src/backend/docs_manager.py
+++ b/meshchatx/src/backend/docs_manager.py
@@ -1,13 +1,14 @@
+import html
+import io
import logging
import os
import re
import shutil
import threading
import zipfile
-import io
-import html
import requests
+
from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
@@ -46,12 +47,13 @@ class DocsManager:
self._update_current_link()
except OSError as e:
- logging.error(f"Failed to create documentation directories: {e}")
+ logging.exception(f"Failed to create documentation directories: {e}")
self.last_error = str(e)
# Initial population of MeshChatX docs
if os.path.exists(self.meshchatx_docs_dir) and os.access(
- self.meshchatx_docs_dir, os.W_OK
+ self.meshchatx_docs_dir,
+ os.W_OK,
):
self.populate_meshchatx_docs()
@@ -115,7 +117,7 @@ class DocsManager:
version_file = os.path.join(self.docs_dir, ".version")
if os.path.exists(version_file):
try:
- with open(version_file, "r") as f:
+ with open(version_file) as f:
return f.read().strip()
except OSError:
pass
@@ -142,7 +144,7 @@ class DocsManager:
# Project root is 3 levels up
this_dir = os.path.dirname(os.path.abspath(__file__))
search_paths.append(
- os.path.abspath(os.path.join(this_dir, "..", "..", "..", "docs"))
+ os.path.abspath(os.path.join(this_dir, "..", "..", "..", "docs")),
)
src_docs = None
@@ -163,13 +165,13 @@ class DocsManager:
# Only copy if source and destination are different
if os.path.abspath(src_path) != os.path.abspath(
- dest_path
+ dest_path,
) and os.access(self.meshchatx_docs_dir, os.W_OK):
shutil.copy2(src_path, dest_path)
# Also pre-render to HTML for easy sharing/viewing
try:
- with open(src_path, "r", encoding="utf-8") as f:
+ with open(src_path, encoding="utf-8") as f:
content = f.read()
html_content = MarkdownRenderer.render(content)
@@ -199,9 +201,9 @@ class DocsManager:
) as f:
f.write(full_html)
except Exception as e:
- logging.error(f"Failed to render {file} to HTML: {e}")
+ logging.exception(f"Failed to render {file} to HTML: {e}")
except Exception as e:
- logging.error(f"Failed to populate MeshChatX docs: {e}")
+ logging.exception(f"Failed to populate MeshChatX docs: {e}")
def get_status(self):
return {
@@ -228,15 +230,15 @@ class DocsManager:
if not os.path.exists(self.meshchatx_docs_dir):
return docs
- for file in os.listdir(self.meshchatx_docs_dir):
- if file.endswith((".md", ".txt")):
- docs.append(
- {
- "name": file,
- "path": file,
- "type": "markdown" if file.endswith(".md") else "text",
- }
- )
+ docs.extend(
+ {
+ "name": file,
+ "path": file,
+ "type": "markdown" if file.endswith(".md") else "text",
+ }
+ for file in os.listdir(self.meshchatx_docs_dir)
+ if file.endswith((".md", ".txt"))
+ )
return sorted(docs, key=lambda x: x["name"])
def get_doc_content(self, path):
@@ -244,7 +246,7 @@ class DocsManager:
if not os.path.exists(full_path):
return None
- with open(full_path, "r", encoding="utf-8", errors="ignore") as f:
+ with open(full_path, encoding="utf-8", errors="ignore") as f:
content = f.read()
if path.endswith(".md"):
@@ -253,12 +255,11 @@ class DocsManager:
"html": MarkdownRenderer.render(content),
"type": "markdown",
}
- else:
- return {
- "content": content,
- "html": f"<pre class='whitespace-pre-wrap font-mono'>{html.escape(content)}</pre>",
- "type": "text",
- }
+ return {
+ "content": content,
+ "html": f"<pre class='whitespace-pre-wrap font-mono'>{html.escape(content)}</pre>",
+ "type": "text",
+ }
def export_docs(self):
"""Creates a zip of all docs and returns the bytes."""
@@ -269,7 +270,8 @@ class DocsManager:
for file in files:
file_path = os.path.join(root, file)
rel_path = os.path.join(
- "reticulum-docs", os.path.relpath(file_path, self.docs_dir)
+ "reticulum-docs",
+ os.path.relpath(file_path, self.docs_dir),
)
zip_file.write(file_path, rel_path)
@@ -300,7 +302,9 @@ class DocsManager:
file_path = os.path.join(self.meshchatx_docs_dir, file)
try:
with open(
- file_path, "r", encoding="utf-8", errors="ignore"
+ file_path,
+ encoding="utf-8",
+ errors="ignore",
) as f:
content = f.read()
if query in content.lower():
@@ -320,10 +324,10 @@ class DocsManager:
"path": f"/meshchatx-docs/{file}",
"snippet": snippet,
"source": "MeshChatX",
- }
+ },
)
except Exception as e:
- logging.error(f"Error searching MeshChatX doc {file}: {e}")
+ logging.exception(f"Error searching MeshChatX doc {file}: {e}")
# 2. Search Reticulum Docs
if self.has_docs():
@@ -405,7 +409,7 @@ class DocsManager:
"path": f"/reticulum-docs/{rel_path}",
"snippet": snippet,
"source": "Reticulum",
- }
+ },
)
if len(results) >= 25: # Limit results
@@ -469,7 +473,7 @@ class DocsManager:
downloaded_size += len(chunk)
if total_size > 0:
self.download_progress = int(
- (downloaded_size / total_size) * 90
+ (downloaded_size / total_size) * 90,
)
# Extract
diff --git a/meshchatx/src/backend/identity_context.py b/meshchatx/src/backend/identity_context.py
index 86c59599..7e901a30 100644
--- a/meshchatx/src/backend/identity_context.py
+++ b/meshchatx/src/backend/identity_context.py
@@ -1,28 +1,31 @@
-import os
import asyncio
+import os
import threading
+
import RNS
-from meshchatx.src.backend.database import Database
-from meshchatx.src.backend.integrity_manager import IntegrityManager
-from meshchatx.src.backend.config_manager import ConfigManager
-from meshchatx.src.backend.message_handler import MessageHandler
+
+from meshchatx.src.backend.announce_handler import AnnounceHandler
from meshchatx.src.backend.announce_manager import AnnounceManager
from meshchatx.src.backend.archiver_manager import ArchiverManager
-from meshchatx.src.backend.map_manager import MapManager
+from meshchatx.src.backend.bot_handler import BotHandler
+from meshchatx.src.backend.community_interfaces import CommunityInterfacesManager
+from meshchatx.src.backend.config_manager import ConfigManager
+from meshchatx.src.backend.database import Database
from meshchatx.src.backend.docs_manager import DocsManager
+from meshchatx.src.backend.forwarding_manager import ForwardingManager
+from meshchatx.src.backend.integrity_manager import IntegrityManager
+from meshchatx.src.backend.map_manager import MapManager
+from meshchatx.src.backend.meshchat_utils import create_lxmf_router
+from meshchatx.src.backend.message_handler import MessageHandler
from meshchatx.src.backend.nomadnet_utils import NomadNetworkManager
-from meshchatx.src.backend.telephone_manager import TelephoneManager
-from meshchatx.src.backend.voicemail_manager import VoicemailManager
from meshchatx.src.backend.ringtone_manager import RingtoneManager
from meshchatx.src.backend.rncp_handler import RNCPHandler
-from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
from meshchatx.src.backend.rnpath_handler import RNPathHandler
from meshchatx.src.backend.rnprobe_handler import RNProbeHandler
+from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
+from meshchatx.src.backend.telephone_manager import TelephoneManager
from meshchatx.src.backend.translator_handler import TranslatorHandler
-from meshchatx.src.backend.forwarding_manager import ForwardingManager
-from meshchatx.src.backend.meshchat_utils import create_lxmf_router
-from meshchatx.src.backend.announce_handler import AnnounceHandler
-from meshchatx.src.backend.community_interfaces import CommunityInterfacesManager
+from meshchatx.src.backend.voicemail_manager import VoicemailManager
class IdentityContext:
@@ -71,12 +74,15 @@ class IdentityContext:
self.rnstatus_handler = None
self.rnprobe_handler = None
self.translator_handler = None
+ self.bot_handler = None
self.forwarding_manager = None
self.community_interfaces_manager = None
self.local_lxmf_destination = None
self.announce_handlers = []
self.integrity_manager = IntegrityManager(
- self.storage_path, self.database_path, self.identity_hash
+ self.storage_path,
+ self.database_path,
+ self.identity_hash,
)
self.running = False
@@ -102,7 +108,7 @@ class IdentityContext:
is_ok, issues = self.integrity_manager.check_integrity()
if not is_ok:
print(
- f"INTEGRITY WARNING for {self.identity_hash}: {', '.join(issues)}"
+ f"INTEGRITY WARNING for {self.identity_hash}: {', '.join(issues)}",
)
if not hasattr(self.app, "integrity_issues"):
self.app.integrity_issues = []
@@ -120,7 +126,7 @@ class IdentityContext:
if not self.app.auto_recover and not getattr(self.app, "emergency", False):
raise
print(
- f"Database initialization failed for {self.identity_hash}, attempting recovery: {exc}"
+ f"Database initialization failed for {self.identity_hash}, attempting recovery: {exc}",
)
if not getattr(self.app, "emergency", False):
self.app._run_startup_auto_recovery()
@@ -151,8 +157,8 @@ class IdentityContext:
self.app.get_public_path(),
project_root=os.path.dirname(
os.path.dirname(
- os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
- )
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ ),
),
storage_dir=self.storage_path,
)
@@ -197,7 +203,7 @@ class IdentityContext:
# Register delivery callback
self.message_router.register_delivery_callback(
- lambda msg: self.app.on_lxmf_delivery(msg, context=self)
+ lambda msg: self.app.on_lxmf_delivery(msg, context=self),
)
# 5. Initialize Handlers and Managers
@@ -224,6 +230,15 @@ class IdentityContext:
enabled=translator_enabled,
)
+ self.bot_handler = BotHandler(
+ identity_path=self.storage_path,
+ config_manager=self.config,
+ )
+ try:
+ self.bot_handler.restore_enabled_bots()
+ except Exception as exc:
+ print(f"Failed to restore bots: {exc}")
+
# Initialize managers
self.telephone_manager = TelephoneManager(
self.identity,
@@ -236,17 +251,19 @@ class IdentityContext:
)
self.telephone_manager.on_initiation_status_callback = (
lambda status, target: self.app.on_telephone_initiation_status(
- status, target, context=self
+ status,
+ target,
+ context=self,
)
)
self.telephone_manager.register_ringing_callback(
- lambda call: self.app.on_incoming_telephone_call(call, context=self)
+ lambda call: self.app.on_incoming_telephone_call(call, context=self),
)
self.telephone_manager.register_established_callback(
- lambda call: self.app.on_telephone_call_established(call, context=self)
+ lambda call: self.app.on_telephone_call_established(call, context=self),
)
self.telephone_manager.register_ended_callback(
- lambda call: self.app.on_telephone_call_ended(call, context=self)
+ lambda call: self.app.on_telephone_call_ended(call, context=self),
)
# Only initialize telephone hardware/profile if not in emergency mode
@@ -287,7 +304,7 @@ class IdentityContext:
):
if not self.docs_manager.has_docs():
print(
- f"Triggering initial documentation download for {self.identity_hash}..."
+ f"Triggering initial documentation download for {self.identity_hash}...",
)
self.docs_manager.update_docs()
self.config.initial_docs_download_attempted.set(True)
@@ -338,13 +355,23 @@ class IdentityContext:
AnnounceHandler(
"lxst.telephony",
lambda aspect, dh, ai, ad, aph: self.app.on_telephone_announce_received(
- aspect, dh, ai, ad, aph, context=self
+ aspect,
+ dh,
+ ai,
+ ad,
+ aph,
+ context=self,
),
),
AnnounceHandler(
"lxmf.delivery",
lambda aspect, dh, ai, ad, aph: self.app.on_lxmf_announce_received(
- aspect, dh, ai, ad, aph, context=self
+ aspect,
+ dh,
+ ai,
+ ad,
+ aph,
+ context=self,
),
),
AnnounceHandler(
@@ -354,7 +381,12 @@ class IdentityContext:
ai,
ad,
aph: self.app.on_lxmf_propagation_announce_received(
- aspect, dh, ai, ad, aph, context=self
+ aspect,
+ dh,
+ ai,
+ ad,
+ aph,
+ context=self,
),
),
AnnounceHandler(
@@ -364,7 +396,12 @@ class IdentityContext:
ai,
ad,
aph: self.app.on_nomadnet_node_announce_received(
- aspect, dh, ai, ad, aph, context=self
+ aspect,
+ dh,
+ ai,
+ ad,
+ aph,
+ context=self,
),
),
]
@@ -389,7 +426,7 @@ class IdentityContext:
if self.message_router:
if hasattr(self.message_router, "delivery_destinations"):
for dest_hash in list(
- self.message_router.delivery_destinations.keys()
+ self.message_router.delivery_destinations.keys(),
):
dest = self.message_router.delivery_destinations[dest_hash]
RNS.Transport.deregister_destination(dest)
@@ -399,7 +436,7 @@ class IdentityContext:
and self.message_router.propagation_destination
):
RNS.Transport.deregister_destination(
- self.message_router.propagation_destination
+ self.message_router.propagation_destination,
)
if self.telephone_manager and self.telephone_manager.telephone:
@@ -408,7 +445,7 @@ class IdentityContext:
and self.telephone_manager.telephone.destination
):
RNS.Transport.deregister_destination(
- self.telephone_manager.telephone.destination
+ self.telephone_manager.telephone.destination,
)
self.app.cleanup_rns_state_for_identity(self.identity.hash)
@@ -423,7 +460,7 @@ class IdentityContext:
self.message_router.exit_handler()
except Exception as e:
print(
- f"Error while tearing down LXMRouter for {self.identity_hash}: {e}"
+ f"Error while tearing down LXMRouter for {self.identity_hash}: {e}",
)
# 4. Stop telephone and voicemail
@@ -432,16 +469,22 @@ class IdentityContext:
self.telephone_manager.teardown()
except Exception as e:
print(
- f"Error while tearing down telephone for {self.identity_hash}: {e}"
+ f"Error while tearing down telephone for {self.identity_hash}: {e}",
)
+ if self.bot_handler:
+ try:
+ self.bot_handler.stop_all()
+ except Exception as e:
+ print(f"Error while stopping bots for {self.identity_hash}: {e}")
+
if self.database:
try:
# 1. Checkpoint WAL and close database cleanly to ensure file is stable for hashing
self.database._checkpoint_and_close()
except Exception as e:
print(
- f"Error closing database during teardown for {self.identity_hash}: {e}"
+ f"Error closing database during teardown for {self.identity_hash}: {e}",
)
# 2. Save integrity manifest AFTER closing to capture final stable state
diff --git a/meshchatx/src/backend/identity_manager.py b/meshchatx/src/backend/identity_manager.py
index 2e66de1b..328e1497 100644
--- a/meshchatx/src/backend/identity_manager.py
+++ b/meshchatx/src/backend/identity_manager.py
@@ -50,7 +50,7 @@ class IdentityManager:
metadata = None
if os.path.exists(metadata_path):
try:
- with open(metadata_path, "r") as f:
+ with open(metadata_path) as f:
metadata = json.load(f)
except Exception:
pass
@@ -62,10 +62,10 @@ class IdentityManager:
"display_name": metadata.get("display_name", "Anonymous Peer"),
"icon_name": metadata.get("icon_name"),
"icon_foreground_colour": metadata.get(
- "icon_foreground_colour"
+ "icon_foreground_colour",
),
"icon_background_colour": metadata.get(
- "icon_background_colour"
+ "icon_background_colour",
),
"lxmf_address": metadata.get("lxmf_address"),
"lxst_address": metadata.get("lxst_address"),
@@ -137,14 +137,17 @@ class IdentityManager:
def create_identity(self, display_name=None):
new_identity = RNS.Identity(create_keys=True)
- identity_hash = new_identity.hash.hex()
+ return self._save_new_identity(new_identity, display_name or "Anonymous Peer")
+
+ def _save_new_identity(self, identity, display_name):
+ identity_hash = identity.hash.hex()
identity_dir = os.path.join(self.storage_dir, "identities", identity_hash)
os.makedirs(identity_dir, exist_ok=True)
identity_file = os.path.join(identity_dir, "identity")
with open(identity_file, "wb") as f:
- f.write(new_identity.get_private_key())
+ f.write(identity.get_private_key())
db_path = os.path.join(identity_dir, "database.db")
@@ -160,7 +163,7 @@ class IdentityManager:
# Save metadata
metadata = {
- "display_name": display_name or "Anonymous Peer",
+ "display_name": display_name,
"icon_name": None,
"icon_foreground_colour": None,
"icon_background_colour": None,
@@ -171,7 +174,7 @@ class IdentityManager:
return {
"hash": identity_hash,
- "display_name": display_name or "Anonymous Peer",
+ "display_name": display_name,
}
def update_metadata_cache(self, identity_hash: str, metadata: dict):
@@ -185,7 +188,7 @@ class IdentityManager:
existing_metadata = {}
if os.path.exists(metadata_path):
try:
- with open(metadata_path, "r") as f:
+ with open(metadata_path) as f:
existing_metadata = json.load(f)
except Exception:
pass
@@ -206,20 +209,20 @@ class IdentityManager:
return False
def restore_identity_from_bytes(self, identity_bytes: bytes) -> dict:
- target_path = self.identity_file_path or os.path.join(
- self.storage_dir,
- "identity",
- )
- os.makedirs(os.path.dirname(target_path), exist_ok=True)
- with open(target_path, "wb") as f:
- f.write(identity_bytes)
- return {"path": target_path, "size": os.path.getsize(target_path)}
+ try:
+ # We use RNS.Identity.from_bytes to validate and get the hash
+ identity = RNS.Identity.from_bytes(identity_bytes)
+ if not identity:
+ raise ValueError("Could not load identity from bytes")
+
+ return self._save_new_identity(identity, "Restored Identity")
+ except Exception as exc:
+ raise ValueError(f"Failed to restore identity: {exc}")
def restore_identity_from_base32(self, base32_value: str) -> dict:
try:
identity_bytes = base64.b32decode(base32_value, casefold=True)
+ return self.restore_identity_from_bytes(identity_bytes)
except Exception as exc:
msg = f"Invalid base32 identity: {exc}"
raise ValueError(msg) from exc
-
- return self.restore_identity_from_bytes(identity_bytes)
diff --git a/meshchatx/src/backend/integrity_manager.py b/meshchatx/src/backend/integrity_manager.py
index 346a0755..041446dd 100644
--- a/meshchatx/src/backend/integrity_manager.py
+++ b/meshchatx/src/backend/integrity_manager.py
@@ -1,8 +1,8 @@
-import os
import hashlib
import json
-from pathlib import Path
+import os
from datetime import UTC, datetime
+from pathlib import Path
class IntegrityManager:
@@ -30,7 +30,7 @@ class IntegrityManager:
return True, ["Initial run - no manifest yet"]
try:
- with open(self.manifest_path, "r") as f:
+ with open(self.manifest_path) as f:
manifest = json.load(f)
issues = []
@@ -39,7 +39,7 @@ class IntegrityManager:
db_rel = str(self.database_path.relative_to(self.storage_dir))
actual_db_hash = self._hash_file(self.database_path)
if actual_db_hash and actual_db_hash != manifest.get("files", {}).get(
- db_rel
+ db_rel,
):
issues.append(f"Database modified: {db_rel}")
@@ -70,7 +70,8 @@ class IntegrityManager:
m_time = manifest.get("time", "Unknown")
m_id = manifest.get("identity", "Unknown")
issues.insert(
- 0, f"Last integrity snapshot: {m_date} {m_time} (Identity: {m_id})"
+ 0,
+ f"Last integrity snapshot: {m_date} {m_time} (Identity: {m_id})",
)
# Check if identity matches
@@ -84,7 +85,7 @@ class IntegrityManager:
self.issues = issues
return len(issues) == 0, issues
except Exception as e:
- return False, [f"Integrity check failed: {str(e)}"]
+ return False, [f"Integrity check failed: {e!s}"]
def save_manifest(self):
"""Snapshot the current state of critical files."""
diff --git a/meshchatx/src/backend/interfaces/WebsocketServerInterface.py b/meshchatx/src/backend/interfaces/WebsocketServerInterface.py
index fe7245bf..4695ef80 100644
--- a/meshchatx/src/backend/interfaces/WebsocketServerInterface.py
+++ b/meshchatx/src/backend/interfaces/WebsocketServerInterface.py
@@ -3,10 +3,11 @@ import time
import RNS
from RNS.Interfaces.Interface import Interface
+from websockets.sync.server import Server, ServerConnection, serve
+
from meshchatx.src.backend.interfaces.WebsocketClientInterface import (
WebsocketClientInterface,
)
-from websockets.sync.server import Server, ServerConnection, serve
class WebsocketServerInterface(Interface):
diff --git a/meshchatx/src/backend/lxmf_utils.py b/meshchatx/src/backend/lxmf_utils.py
index 6fdc6d57..9153d751 100644
--- a/meshchatx/src/backend/lxmf_utils.py
+++ b/meshchatx/src/backend/lxmf_utils.py
@@ -1,6 +1,8 @@
import base64
import json
+
import LXMF
+
from meshchatx.src.backend.telemetry_utils import Telemeter
diff --git a/meshchatx/src/backend/map_manager.py b/meshchatx/src/backend/map_manager.py
index e04d908b..98ee6bb9 100644
--- a/meshchatx/src/backend/map_manager.py
+++ b/meshchatx/src/backend/map_manager.py
@@ -191,9 +191,9 @@ class MapManager:
for z in zoom_levels:
x1, y1 = self._lonlat_to_tile(min_lon, max_lat, z)
x2, y2 = self._lonlat_to_tile(max_lon, min_lat, z)
- for x in range(x1, x2 + 1):
- for y in range(y1, y2 + 1):
- tiles_to_download.append((z, x, y))
+ tiles_to_download.extend(
+ (z, x, y) for x in range(x1, x2 + 1) for y in range(y1, y2 + 1)
+ )
total_tiles = len(tiles_to_download)
self._export_progress[export_id]["total"] = total_tiles
@@ -265,7 +265,7 @@ class MapManager:
return None
with concurrent.futures.ThreadPoolExecutor(
- max_workers=max_workers
+ max_workers=max_workers,
) as executor:
future_to_tile = {
executor.submit(download_tile, tile): tile
@@ -299,7 +299,8 @@ class MapManager:
):
try:
cursor.executemany(
- "INSERT INTO tiles VALUES (?, ?, ?, ?)", batch_data
+ "INSERT INTO tiles VALUES (?, ?, ?, ?)",
+ batch_data,
)
conn.commit()
batch_data = []
diff --git a/meshchatx/src/backend/markdown_renderer.py b/meshchatx/src/backend/markdown_renderer.py
index 3ae1ec2c..a31ee0c7 100644
--- a/meshchatx/src/backend/markdown_renderer.py
+++ b/meshchatx/src/backend/markdown_renderer.py
@@ -1,5 +1,5 @@
-import re
import html
+import re
class MarkdownRenderer:
@@ -24,12 +24,15 @@ class MarkdownRenderer:
code = match.group(2)
placeholder = f"[[CB{len(code_blocks)}]]"
code_blocks.append(
- f'<pre class="bg-gray-800 dark:bg-zinc-900 text-zinc-100 dark:text-zinc-100 p-4 rounded-lg my-4 overflow-x-auto border border-gray-700 dark:border-zinc-800 font-mono text-sm"><code class="language-{lang} text-inherit">{code}</code></pre>'
+ f'<pre class="bg-gray-800 dark:bg-zinc-900 text-zinc-100 dark:text-zinc-100 p-4 rounded-lg my-4 overflow-x-auto border border-gray-700 dark:border-zinc-800 font-mono text-sm"><code class="language-{lang} text-inherit">{code}</code></pre>',
)
return placeholder
text = re.sub(
- r"```(\w+)?\n(.*?)\n```", code_block_placeholder, text, flags=re.DOTALL
+ r"```(\w+)?\n(.*?)\n```",
+ code_block_placeholder,
+ text,
+ flags=re.DOTALL,
)
# Horizontal Rules
@@ -134,7 +137,10 @@ class MarkdownRenderer:
return f'<ul class="my-4 space-y-1">{html_items}</ul>'
text = re.sub(
- r"((?:^[*-] .*\n?)+)", unordered_list_repl, text, flags=re.MULTILINE
+ r"((?:^[*-] .*\n?)+)",
+ unordered_list_repl,
+ text,
+ flags=re.MULTILINE,
)
def ordered_list_repl(match):
@@ -146,7 +152,10 @@ class MarkdownRenderer:
return f'<ol class="my-4 space-y-1">{html_items}</ol>'
text = re.sub(
- r"((?:^\d+\. .*\n?)+)", ordered_list_repl, text, flags=re.MULTILINE
+ r"((?:^\d+\. .*\n?)+)",
+ ordered_list_repl,
+ text,
+ flags=re.MULTILINE,
)
# Paragraphs - double newline to p tag
@@ -169,7 +178,7 @@ class MarkdownRenderer:
# Replace single newlines with <br> for line breaks within paragraphs
part = part.replace("\n", "<br>")
processed_parts.append(
- f'<p class="my-4 leading-relaxed text-gray-800 dark:text-zinc-200">{part}</p>'
+ f'<p class="my-4 leading-relaxed text-gray-800 dark:text-zinc-200">{part}</p>',
)
text = "\n".join(processed_parts)
diff --git a/meshchatx/src/backend/meshchat_utils.py b/meshchatx/src/backend/meshchat_utils.py
index f4598252..bd0e8bd2 100644
--- a/meshchatx/src/backend/meshchat_utils.py
+++ b/meshchatx/src/backend/meshchat_utils.py
@@ -9,8 +9,7 @@ from LXMF import LXMRouter
def create_lxmf_router(identity, storagepath, propagation_cost=None):
- """
- Creates an LXMF.LXMRouter instance safely, avoiding signal handler crashes
+ """Creates an LXMF.LXMRouter instance safely, avoiding signal handler crashes
when called from non-main threads.
"""
if propagation_cost is None:
diff --git a/meshchatx/src/backend/message_handler.py b/meshchatx/src/backend/message_handler.py
index 2bdd299c..4fb1c8a7 100644
--- a/meshchatx/src/backend/message_handler.py
+++ b/meshchatx/src/backend/message_handler.py
@@ -77,7 +77,11 @@ class MessageHandler:
) m2 ON m1.peer_hash = m2.peer_hash AND m1.timestamp = m2.max_ts
LEFT JOIN announces a ON a.destination_hash = m1.peer_hash
LEFT JOIN custom_destination_display_names c ON c.destination_hash = m1.peer_hash
- LEFT JOIN contacts con ON con.remote_identity_hash = m1.peer_hash
+ LEFT JOIN contacts con ON (
+ con.remote_identity_hash = m1.peer_hash OR
+ con.lxmf_address = m1.peer_hash OR
+ con.lxst_address = m1.peer_hash
+ )
LEFT JOIN lxmf_user_icons i ON i.destination_hash = m1.peer_hash
LEFT JOIN lxmf_conversation_read_state r ON r.destination_hash = m1.peer_hash
"""
@@ -86,7 +90,7 @@ class MessageHandler:
if filter_unread:
where_clauses.append(
- "(r.last_read_at IS NULL OR m1.timestamp > strftime('%s', r.last_read_at))"
+ "(r.last_read_at IS NULL OR m1.timestamp > strftime('%s', r.last_read_at))",
)
if filter_failed:
@@ -94,7 +98,7 @@ class MessageHandler:
if filter_has_attachments:
where_clauses.append(
- "(m1.fields IS NOT NULL AND m1.fields != '{}' AND m1.fields != '')"
+ "(m1.fields IS NOT NULL AND m1.fields != '{}' AND m1.fields != '')",
)
if search:
@@ -105,7 +109,7 @@ class MessageHandler:
OR m1.peer_hash IN (SELECT peer_hash FROM lxmf_messages WHERE title LIKE ? OR content LIKE ?))
""")
params.extend(
- [like_term, like_term, like_term, like_term, like_term, like_term]
+ [like_term, like_term, like_term, like_term, like_term, like_term],
)
if where_clauses:
diff --git a/meshchatx/src/backend/persistent_log_handler.py b/meshchatx/src/backend/persistent_log_handler.py
index 61d38427..3dadde35 100644
--- a/meshchatx/src/backend/persistent_log_handler.py
+++ b/meshchatx/src/backend/persistent_log_handler.py
@@ -64,7 +64,8 @@ class PersistentLogHandler(logging.Handler):
# Regex to extract IP and User-Agent from aiohttp access log
# Format: IP [date] "GET ..." status size "referer" "User-Agent"
match = re.search(
- r"^([\d\.\:]+) .* \"[^\"]+\" \d+ \d+ \"[^\"]*\" \"([^\"]+)\"", message
+ r"^([\d\.\:]+) .* \"[^\"]+\" \d+ \d+ \"[^\"]*\" \"([^\"]+)\"",
+ message,
)
if match:
ip = match.group(1)
@@ -180,7 +181,13 @@ class PersistentLogHandler(logging.Handler):
self.flush_lock.release()
def get_logs(
- self, limit=100, offset=0, search=None, level=None, module=None, is_anomaly=None
+ self,
+ limit=100,
+ offset=0,
+ search=None,
+ level=None,
+ module=None,
+ is_anomaly=None,
):
if self.database:
# Flush current buffer first to ensure we have latest logs
@@ -196,34 +203,33 @@ class PersistentLogHandler(logging.Handler):
module=module,
is_anomaly=is_anomaly,
)
- else:
- # Fallback to in-memory buffer if DB not yet available
- logs = list(self.logs_buffer)
- if search:
- logs = [
- log
- for log in logs
- if search.lower() in log["message"].lower()
- or search.lower() in log["module"].lower()
- ]
- if level:
- logs = [log for log in logs if log["level"] == level]
- if is_anomaly is not None:
- logs = [
- log
- for log in logs
- if log["is_anomaly"] == (1 if is_anomaly else 0)
- ]
-
- # Sort descending
- logs.sort(key=lambda x: x["timestamp"], reverse=True)
- return logs[offset : offset + limit]
+ # Fallback to in-memory buffer if DB not yet available
+ logs = list(self.logs_buffer)
+ if search:
+ logs = [
+ log
+ for log in logs
+ if search.lower() in log["message"].lower()
+ or search.lower() in log["module"].lower()
+ ]
+ if level:
+ logs = [log for log in logs if log["level"] == level]
+ if is_anomaly is not None:
+ logs = [
+ log for log in logs if log["is_anomaly"] == (1 if is_anomaly else 0)
+ ]
+
+ # Sort descending
+ logs.sort(key=lambda x: x["timestamp"], reverse=True)
+ return logs[offset : offset + limit]
def get_total_count(self, search=None, level=None, module=None, is_anomaly=None):
with self.lock:
if self.database:
return self.database.debug_logs.get_total_count(
- search=search, level=level, module=module, is_anomaly=is_anomaly
+ search=search,
+ level=level,
+ module=module,
+ is_anomaly=is_anomaly,
)
- else:
- return len(self.logs_buffer)
+ return len(self.logs_buffer)
diff --git a/meshchatx/src/backend/recovery/crash_recovery.py b/meshchatx/src/backend/recovery/crash_recovery.py
index 9e88b72b..f673012f 100644
--- a/meshchatx/src/backend/recovery/crash_recovery.py
+++ b/meshchatx/src/backend/recovery/crash_recovery.py
@@ -1,16 +1,16 @@
-import sys
import os
-import traceback
import platform
import shutil
import sqlite3
+import sys
+import traceback
+
import psutil
import RNS
class CrashRecovery:
- """
- A diagnostic utility that intercepts application crashes and provides
+ """A diagnostic utility that intercepts application crashes and provides
meaningful error reports and system state analysis.
"""
@@ -33,18 +33,14 @@ class CrashRecovery:
self.enabled = False
def install(self):
- """
- Installs the crash recovery exception hook into the system.
- """
+ """Installs the crash recovery exception hook into the system."""
if not self.enabled:
return
sys.excepthook = self.handle_exception
def disable(self):
- """
- Disables the crash recovery system manually.
- """
+ """Disables the crash recovery system manually."""
self.enabled = False
def update_paths(
@@ -54,9 +50,7 @@ class CrashRecovery:
public_dir=None,
reticulum_config_dir=None,
):
- """
- Updates the internal paths used for system diagnosis.
- """
+ """Updates the internal paths used for system diagnosis."""
if storage_dir:
self.storage_dir = storage_dir
if database_path:
@@ -67,9 +61,7 @@ class CrashRecovery:
self.reticulum_config_dir = reticulum_config_dir
def handle_exception(self, exc_type, exc_value, exc_traceback):
- """
- Intercepts unhandled exceptions to provide a detailed diagnosis report.
- """
+ """Intercepts unhandled exceptions to provide a detailed diagnosis report."""
# Let keyboard interrupts pass through normally
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(exc_type, exc_value, exc_traceback)
@@ -100,13 +92,13 @@ class CrashRecovery:
out.write("Recovery Suggestions:\n")
out.write(" 1. Review the 'System Environment Diagnosis' section above.\n")
out.write(
- " 2. Verify that all dependencies are installed (poetry install or pip install -r requirements.txt).\n"
+ " 2. Verify that all dependencies are installed (poetry install or pip install -r requirements.txt).\n",
)
out.write(
- " 3. If database corruption is suspected, try starting with --auto-recover.\n"
+ " 3. If database corruption is suspected, try starting with --auto-recover.\n",
)
out.write(
- " 4. If the issue persists, report it to Ivan over another LXMF client: 7cc8d66b4f6a0e0e49d34af7f6077b5a\n"
+ " 4. If the issue persists, report it to Ivan over another LXMF client: 7cc8d66b4f6a0e0e49d34af7f6077b5a\n",
)
out.write("=" * 70 + "\n\n")
out.flush()
@@ -115,12 +107,10 @@ class CrashRecovery:
sys.exit(1)
def run_diagnosis(self, file=sys.stderr):
- """
- Performs a series of OS-agnostic checks on the application's environment.
- """
+ """Performs a series of OS-agnostic checks on the application's environment."""
# Basic System Info
file.write(
- f"- OS: {platform.system()} {platform.release()} ({platform.machine()})\n"
+ f"- OS: {platform.system()} {platform.release()} ({platform.machine()})\n",
)
file.write(f"- Python: {sys.version.split()[0]}\n")
@@ -128,7 +118,7 @@ class CrashRecovery:
try:
mem = psutil.virtual_memory()
file.write(
- f"- Memory: {mem.percent}% used ({mem.available / (1024**2):.1f} MB available)\n"
+ f"- Memory: {mem.percent}% used ({mem.available / (1024**2):.1f} MB available)\n",
)
if mem.percent > 95:
file.write(" [CRITICAL] System memory is dangerously low!\n")
@@ -140,12 +130,12 @@ class CrashRecovery:
file.write(f"- Storage Path: {self.storage_dir}\n")
if not os.path.exists(self.storage_dir):
file.write(
- " [ERROR] Storage path does not exist. Check MESHCHAT_STORAGE_DIR.\n"
+ " [ERROR] Storage path does not exist. Check MESHCHAT_STORAGE_DIR.\n",
)
else:
if not os.access(self.storage_dir, os.W_OK):
file.write(
- " [ERROR] Storage path is NOT writable. Check filesystem permissions.\n"
+ " [ERROR] Storage path is NOT writable. Check filesystem permissions.\n",
)
try:
@@ -154,7 +144,7 @@ class CrashRecovery:
file.write(f" - Disk Space: {free_mb:.1f} MB free\n")
if free_mb < 50:
file.write(
- " [CRITICAL] Disk space is critically low (< 50MB)!\n"
+ " [CRITICAL] Disk space is critically low (< 50MB)!\n",
)
except Exception:
pass
@@ -165,27 +155,28 @@ class CrashRecovery:
if os.path.exists(self.database_path):
if os.path.getsize(self.database_path) == 0:
file.write(
- " [WARNING] Database file exists but is empty (0 bytes).\n"
+ " [WARNING] Database file exists but is empty (0 bytes).\n",
)
else:
try:
# Open in read-only mode for safety during crash handling
conn = sqlite3.connect(
- f"file:{self.database_path}?mode=ro", uri=True
+ f"file:{self.database_path}?mode=ro",
+ uri=True,
)
cursor = conn.cursor()
cursor.execute("PRAGMA integrity_check")
res = cursor.fetchone()[0]
if res != "ok":
file.write(
- f" [ERROR] Database corruption detected: {res}\n"
+ f" [ERROR] Database corruption detected: {res}\n",
)
else:
file.write(" - Integrity: OK\n")
conn.close()
except sqlite3.DatabaseError as e:
file.write(
- f" [ERROR] Database is unreadable or not a SQLite file: {e}\n"
+ f" [ERROR] Database is unreadable or not a SQLite file: {e}\n",
)
except Exception as e:
file.write(f" [ERROR] Database check failed: {e}\n")
@@ -197,13 +188,13 @@ class CrashRecovery:
file.write(f"- Frontend Assets: {self.public_dir}\n")
if not os.path.exists(self.public_dir):
file.write(
- " [ERROR] Frontend directory is missing. Web interface will fail to load.\n"
+ " [ERROR] Frontend directory is missing. Web interface will fail to load.\n",
)
else:
index_path = os.path.join(self.public_dir, "index.html")
if not os.path.exists(index_path):
file.write(
- " [ERROR] index.html not found in frontend directory!\n"
+ " [ERROR] index.html not found in frontend directory!\n",
)
else:
file.write(" - Frontend Status: Assets verified\n")
@@ -212,9 +203,7 @@ class CrashRecovery:
self.run_reticulum_diagnosis(file=file)
def run_reticulum_diagnosis(self, file=sys.stderr):
- """
- Diagnoses the Reticulum Network Stack environment.
- """
+ """Diagnoses the Reticulum Network Stack environment."""
file.write("- Reticulum Network Stack:\n")
# Check config directory
@@ -231,11 +220,11 @@ class CrashRecovery:
else:
try:
# Basic config validation
- with open(config_file, "r") as f:
+ with open(config_file) as f:
content = f.read()
if "[reticulum]" not in content:
file.write(
- " [ERROR] Reticulum config file is invalid (missing [reticulum] section).\n"
+ " [ERROR] Reticulum config file is invalid (missing [reticulum] section).\n",
)
else:
file.write(" - Config File: OK\n")
@@ -255,7 +244,7 @@ class CrashRecovery:
if os.path.exists(logfile):
file.write(f" - Recent Log Entries ({logfile}):\n")
try:
- with open(logfile, "r") as f:
+ with open(logfile) as f:
lines = f.readlines()
if not lines:
file.write(" (Log file is empty)\n")
@@ -283,7 +272,7 @@ class CrashRecovery:
file.write(f" > {iface} [{status}]\n")
else:
file.write(
- " - Active Interfaces: None registered (Reticulum may not be initialized yet)\n"
+ " - Active Interfaces: None registered (Reticulum may not be initialized yet)\n",
)
except Exception:
pass
@@ -295,7 +284,7 @@ class CrashRecovery:
for conn in psutil.net_connections():
if conn.laddr.port == port and conn.status == "LISTEN":
file.write(
- f" [ALERT] Port {port} is already in use by PID {conn.pid}. Potential conflict.\n"
+ f" [ALERT] Port {port} is already in use by PID {conn.pid}. Potential conflict.\n",
)
except Exception:
pass
diff --git a/meshchatx/src/backend/rnpath_handler.py b/meshchatx/src/backend/rnpath_handler.py
index 032528c3..e58237d0 100644
--- a/meshchatx/src/backend/rnpath_handler.py
+++ b/meshchatx/src/backend/rnpath_handler.py
@@ -55,7 +55,7 @@ class RNPathHandler:
"timestamp": entry.get("timestamp"),
"announce_hash": announce_hash,
"state": state,
- }
+ },
)
# Sort: Responsive first, then by hops, then by interface
@@ -64,19 +64,23 @@ class RNPathHandler:
0 if e["state"] == RNS.Transport.STATE_RESPONSIVE else 1,
e["hops"],
e["interface"],
- )
+ ),
)
total = len(formatted_table)
responsive_count = len(
- [e for e in formatted_table if e["state"] == RNS.Transport.STATE_RESPONSIVE]
+ [
+ e
+ for e in formatted_table
+ if e["state"] == RNS.Transport.STATE_RESPONSIVE
+ ],
)
unresponsive_count = len(
[
e
for e in formatted_table
if e["state"] == RNS.Transport.STATE_UNRESPONSIVE
- ]
+ ],
)
# Pagination
@@ -96,17 +100,16 @@ class RNPathHandler:
def get_rate_table(self):
table = self.reticulum.get_rate_table()
- formatted_table = []
- for entry in table:
- formatted_table.append(
- {
- "hash": entry["hash"].hex(),
- "last": entry["last"],
- "timestamps": entry["timestamps"],
- "rate_violations": entry["rate_violations"],
- "blocked_until": entry["blocked_until"],
- }
- )
+ formatted_table = [
+ {
+ "hash": entry["hash"].hex(),
+ "last": entry["last"],
+ "timestamps": entry["timestamps"],
+ "rate_violations": entry["rate_violations"],
+ "blocked_until": entry["blocked_until"],
+ }
+ for entry in table
+ ]
return sorted(formatted_table, key=lambda e: e["last"])
def drop_path(self, destination_hash: str) -> bool:
diff --git a/meshchatx/src/backend/rnstatus_handler.py b/meshchatx/src/backend/rnstatus_handler.py
index 50aa86cb..783d3d4b 100644
--- a/meshchatx/src/backend/rnstatus_handler.py
+++ b/meshchatx/src/backend/rnstatus_handler.py
@@ -1,5 +1,6 @@
import time
from typing import Any
+
import RNS
diff --git a/meshchatx/src/backend/telephone_manager.py b/meshchatx/src/backend/telephone_manager.py
index 1779df76..44aa1a54 100644
--- a/meshchatx/src/backend/telephone_manager.py
+++ b/meshchatx/src/backend/telephone_manager.py
@@ -41,7 +41,11 @@ class TelephoneManager:
# 6: STATUS_ESTABLISHED
def __init__(
- self, identity: RNS.Identity, config_manager=None, storage_dir=None, db=None
+ self,
+ identity: RNS.Identity,
+ config_manager=None,
+ storage_dir=None,
+ db=None,
):
self.identity = identity
self.config_manager = config_manager
@@ -177,7 +181,8 @@ class TelephoneManager:
# Pack display name in LXMF-compatible app data format
app_data = msgpack.packb([display_name, None, None])
self.telephone.destination.announce(
- app_data=app_data, attached_interface=attached_interface
+ app_data=app_data,
+ attached_interface=attached_interface,
)
self.telephone.last_announce = time.time()
else:
@@ -190,7 +195,8 @@ class TelephoneManager:
if self.on_initiation_status_callback:
try:
self.on_initiation_status_callback(
- self.initiation_status, self.initiation_target_hash
+ self.initiation_status,
+ self.initiation_target_hash,
)
except Exception as e:
RNS.log(
@@ -229,7 +235,7 @@ class TelephoneManager:
if not announce:
# 3) By identity_hash field (if user entered identity hash but we missed recall, or other announce types)
announces = self.db.announces.get_filtered_announces(
- identity_hash=target_hash_hex
+ identity_hash=target_hash_hex,
)
if announces:
announce = announces[0]
@@ -248,7 +254,7 @@ class TelephoneManager:
if announce.get("identity_public_key"):
try:
return RNS.Identity.from_bytes(
- base64.b64decode(announce["identity_public_key"])
+ base64.b64decode(announce["identity_public_key"]),
)
except Exception:
pass
@@ -297,7 +303,7 @@ class TelephoneManager:
# Use a thread for the blocking LXST call, but monitor status for early exit
# if established elsewhere or timed out/hung up
call_task = asyncio.create_task(
- asyncio.to_thread(self.telephone.call, destination_identity)
+ asyncio.to_thread(self.telephone.call, destination_identity),
)
start_wait = time.time()
@@ -340,7 +346,7 @@ class TelephoneManager:
return self.telephone.active_call
except Exception as e:
- self._update_initiation_status(f"Failed: {str(e)}")
+ self._update_initiation_status(f"Failed: {e!s}")
await asyncio.sleep(3)
raise
finally:
@@ -379,7 +385,8 @@ class TelephoneManager:
self.telephone.audio_input.start()
except Exception as e:
RNS.log(
- f"Failed to start audio input for unmute: {e}", RNS.LOG_ERROR
+ f"Failed to start audio input for unmute: {e}",
+ RNS.LOG_ERROR,
)
# Still call the internal method just in case
@@ -415,7 +422,8 @@ class TelephoneManager:
self.telephone.audio_output.start()
except Exception as e:
RNS.log(
- f"Failed to start audio output for unmute: {e}", RNS.LOG_ERROR
+ f"Failed to start audio output for unmute: {e}",
+ RNS.LOG_ERROR,
)
# Still call the internal method just in case
diff --git a/meshchatx/src/backend/voicemail_manager.py b/meshchatx/src/backend/voicemail_manager.py
index 927fb968..1dcc0bb6 100644
--- a/meshchatx/src/backend/voicemail_manager.py
+++ b/meshchatx/src/backend/voicemail_manager.py
@@ -556,7 +556,8 @@ class VoicemailManager:
os.remove(filepath)
os.rename(temp_path, filepath)
RNS.log(
- f"Voicemail: Fixed recording format for {filepath}", RNS.LOG_DEBUG
+ f"Voicemail: Fixed recording format for {filepath}",
+ RNS.LOG_DEBUG,
)
else:
RNS.log(
diff --git a/meshchatx/src/backend/web_audio_bridge.py b/meshchatx/src/backend/web_audio_bridge.py
index 0aa5a197..096f17dd 100644
--- a/meshchatx/src/backend/web_audio_bridge.py
+++ b/meshchatx/src/backend/web_audio_bridge.py
@@ -1,7 +1,6 @@
import asyncio
import json
import threading
-from typing import Optional
import numpy as np
import RNS
@@ -89,9 +88,9 @@ class WebAudioBridge:
self.telephone_manager = telephone_manager
self.config_manager = config_manager
self.clients = set()
- self.tx_source: Optional[WebAudioSource] = None
- self.rx_sink: Optional[WebAudioSink] = None
- self.rx_tee: Optional[Tee] = None
+ self.tx_source: WebAudioSource | None = None
+ self.rx_sink: WebAudioSink | None = None
+ self.rx_tee: Tee | None = None
self.loop = asyncio.get_event_loop()
self.lock = threading.Lock()
@@ -137,8 +136,8 @@ class WebAudioBridge:
{
"type": "web_audio.ready",
"frame_ms": frame_ms,
- }
- )
+ },
+ ),
)
def push_client_frame(self, pcm_bytes: bytes):
@@ -173,7 +172,8 @@ class WebAudioBridge:
tele.transmit_mixer.start()
except Exception as exc: # noqa: BLE001
RNS.log(
- f"WebAudioBridge: failed to swap transmit path: {exc}", RNS.LOG_ERROR
+ f"WebAudioBridge: failed to swap transmit path: {exc}",
+ RNS.LOG_ERROR,
)
def _ensure_rx_tee(self, tele):
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index b7ca8b88..3a7c273b 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -630,6 +630,7 @@ export default {
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
+ GlobalEmitter.off("config-updated", this.onConfigUpdatedExternally);
},
mounted() {
// listen for websocket messages
@@ -650,6 +651,8 @@ export default {
this.syncPropagationNode();
});
+ GlobalEmitter.on("config-updated", this.onConfigUpdatedExternally);
+
GlobalEmitter.on("keyboard-shortcut", (action) => {
this.handleKeyboardShortcut(action);
});
@@ -691,6 +694,12 @@ export default {
}, 15000);
},
methods: {
+ onConfigUpdatedExternally(newConfig) {
+ if (!newConfig) return;
+ this.config = newConfig;
+ GlobalState.config = newConfig;
+ this.displayName = newConfig.display_name;
+ },
applyThemePreference(theme) {
const mode = theme === "dark" ? "dark" : "light";
if (typeof document !== "undefined") {
diff --git a/meshchatx/src/frontend/components/NotificationBell.vue b/meshchatx/src/frontend/components/NotificationBell.vue
index 64170e89..681f63a4 100644
--- a/meshchatx/src/frontend/components/NotificationBell.vue
+++ b/meshchatx/src/frontend/components/NotificationBell.vue
@@ -24,13 +24,23 @@
<div class="p-4 border-b border-gray-200 dark:border-zinc-800">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Notifications</h3>
- <button
- type="button"
- class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
- @click="closeDropdown"
- >
- <MaterialDesignIcon icon-name="close" class="w-5 h-5" />
- </button>
+ <div class="flex items-center gap-2">
+ <button
+ v-if="notifications.length > 0"
+ type="button"
+ class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
+ @click.stop="clearAllNotifications"
+ >
+ Clear
+ </button>
+ <button
+ type="button"
+ class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
+ @click="closeDropdown"
+ >
+ <MaterialDesignIcon icon-name="close" class="w-5 h-5" />
+ </button>
+ </div>
</div>
</div>
@@ -139,6 +149,7 @@ export default {
},
},
},
+ emits: ["notifications-cleared"],
data() {
return {
isDropdownOpen: false,
@@ -233,6 +244,37 @@ export default {
console.error("Failed to mark notifications as viewed", e);
}
},
+ async clearAllNotifications() {
+ try {
+ await window.axios.post("/api/v1/notifications/mark-as-viewed", {
+ destination_hashes: [],
+ notification_ids: [],
+ });
+
+ const response = await window.axios.get("/api/v1/lxmf/conversations");
+ const conversations = response.data.conversations || [];
+
+ for (const conversation of conversations) {
+ if (conversation.is_unread) {
+ try {
+ await window.axios.get(
+ `/api/v1/lxmf/conversations/${conversation.destination_hash}/mark-as-read`
+ );
+ } catch (e) {
+ console.error(`Failed to mark conversation as read: ${conversation.destination_hash}`, e);
+ }
+ }
+ }
+
+ const GlobalState = (await import("../js/GlobalState")).default;
+ GlobalState.unreadConversationsCount = 0;
+
+ await this.loadNotifications();
+ this.$emit("notifications-cleared");
+ } catch (e) {
+ console.error("Failed to clear notifications", e);
+ }
+ },
onNotificationClick(notification) {
this.closeDropdown();
if (notification.type === "lxmf_message") {
diff --git a/meshchatx/src/frontend/components/about/AboutPage.vue b/meshchatx/src/frontend/components/about/AboutPage.vue
index 81fe9257..8014e519 100644
--- a/meshchatx/src/frontend/components/about/AboutPage.vue
+++ b/meshchatx/src/frontend/components/about/AboutPage.vue
@@ -556,6 +556,48 @@
</div>
</div>
+ <!-- Auto Backups -->
+ <div v-if="autoBackups.length > 0" class="space-y-6">
+ <div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
+ <div class="space-y-1">
+ <div
+ class="font-black text-gray-900 dark:text-white text-sm tracking-tight flex items-center gap-2"
+ >
+ <v-icon icon="mdi-history" size="16" class="text-blue-500"></v-icon>
+ Automatic Backups
+ </div>
+ <div class="text-xs text-gray-500">
+ Automated daily snapshots of your database.
+ </div>
+ </div>
+ </div>
+
+ <div class="grid gap-3 sm:grid-cols-2">
+ <div
+ v-for="backup in autoBackups"
+ :key="backup.path"
+ class="flex items-center justify-between p-4 rounded-2xl bg-zinc-50 dark:bg-zinc-900 border border-zinc-100 dark:border-zinc-800 hover:border-blue-500/20 transition-all group"
+ >
+ <div class="flex flex-col">
+ <span
+ class="font-black text-gray-900 dark:text-white text-xs truncate max-w-[150px]"
+ >{{ backup.name }}</span
+ >
+ <span class="text-[10px] font-bold text-gray-400 mt-1 tabular-nums"
+ >{{ formatBytes(backup.size) }} • {{ backup.created_at }}</span
+ >
+ </div>
+ <button
+ type="button"
+ class="secondary-chip !px-3 !py-1 !text-[10px] opacity-0 group-hover:opacity-100"
+ @click="restoreFromSnapshot(backup.path)"
+ >
+ Restore
+ </button>
+ </div>
+ </div>
+ </div>
+
<!-- Identity Section -->
<div class="bg-red-500/5 p-6 rounded-2xl border border-red-500/10 space-y-6">
<div class="flex items-center gap-4 text-red-500">
@@ -686,6 +728,7 @@ export default {
snapshotInProgress: false,
snapshotMessage: "",
snapshotError: "",
+ autoBackups: [],
identityBackupMessage: "",
identityBackupError: "",
identityBase32: "",
@@ -713,6 +756,7 @@ export default {
this.getConfig();
this.getDatabaseHealth();
this.listSnapshots();
+ this.listAutoBackups();
// Update stats every 5 seconds
this.updateInterval = setInterval(() => {
this.getAppInfo();
@@ -738,6 +782,14 @@ export default {
console.log("Failed to list snapshots", e);
}
},
+ async listAutoBackups() {
+ try {
+ const response = await window.axios.get("/api/v1/database/backups");
+ this.autoBackups = response.data;
+ } catch (e) {
+ console.log("Failed to list auto-backups", e);
+ }
+ },
async createSnapshot() {
if (this.snapshotInProgress) return;
this.snapshotInProgress = true;
@@ -1071,7 +1123,7 @@ export default {
const response = await window.axios.post("/api/v1/identity/restore", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
- this.identityRestoreMessage = response.data.message || "Identity restored. Restart app.";
+ this.identityRestoreMessage = response.data.message || "Identity imported.";
} catch (e) {
this.identityRestoreError = "Identity restore failed";
console.log(e);
@@ -1094,7 +1146,7 @@ export default {
const response = await window.axios.post("/api/v1/identity/restore", {
base32: this.identityRestoreBase32.trim(),
});
- this.identityRestoreMessage = response.data.message || "Identity restored. Restart app.";
+ this.identityRestoreMessage = response.data.message || "Identity imported.";
} catch (e) {
this.identityRestoreError = "Identity restore failed";
console.log(e);
diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue
index a483612b..e0f0e0db 100644
--- a/meshchatx/src/frontend/components/call/CallPage.vue
+++ b/meshchatx/src/frontend/components/call/CallPage.vue
@@ -516,12 +516,18 @@
:label="$t('call.allow_calls_from_contacts_only')"
@update:model-value="toggleAllowCallsFromContactsOnly"
/>
- <Toggle
- id="web-audio-toggle"
- :model-value="config?.telephone_web_audio_enabled"
- label="Browser/Electron Audio"
- @update:model-value="onToggleWebAudio"
- />
+ <div class="flex flex-col gap-1">
+ <Toggle
+ id="web-audio-toggle"
+ :model-value="config?.telephone_web_audio_enabled"
+ label="Web Audio Bridge"
+ @update:model-value="onToggleWebAudio"
+ />
+ <div class="text-xs text-gray-500 dark:text-zinc-400 px-1">
+ Web audio bridge allows web/electron to hook into LXST backend for
+ passing microphone and audio streams to active telephone calls.
+ </div>
+ </div>
</div>
<div class="flex flex-col gap-2 shrink-0">
<!-- <Toggle
@@ -676,6 +682,7 @@
<div class="relative shrink-0">
<LxmfUserIcon
:custom-image="
+ entry.contact_image ||
getContactByHash(entry.remote_identity_hash)?.custom_image
"
:icon-name="entry.remote_icon ? entry.remote_icon.icon_name : ''"
@@ -845,18 +852,12 @@
<div class="flex items-center space-x-4">
<div class="shrink-0">
<LxmfUserIcon
- v-if="announce.lxmf_user_icon"
- :icon-name="announce.lxmf_user_icon.icon_name"
- :icon-foreground-colour="announce.lxmf_user_icon.foreground_colour"
- :icon-background-colour="announce.lxmf_user_icon.background_colour"
+ :custom-image="announce.contact_image"
+ :icon-name="announce.lxmf_user_icon?.icon_name"
+ :icon-foreground-colour="announce.lxmf_user_icon?.foreground_colour"
+ :icon-background-colour="announce.lxmf_user_icon?.background_colour"
class="size-10"
/>
- <div
- v-else
- class="size-10 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-500 flex items-center justify-center font-bold shrink-0"
- >
- {{ (announce.display_name || "A")[0].toUpperCase() }}
- </div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
@@ -1461,16 +1462,34 @@
</div>
</div>
<div class="flex items-center justify-between mt-1">
- <span
- class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
- :title="contact.remote_identity_hash"
- @click.stop="copyHash(contact.remote_identity_hash)"
- >
- {{ formatDestinationHash(contact.remote_identity_hash) }}
- </span>
+ <div class="flex flex-col min-w-0">
+ <span
+ class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
+ :title="contact.remote_identity_hash"
+ @click.stop="copyHash(contact.remote_identity_hash)"
+ >
+ ID: {{ formatDestinationHash(contact.remote_identity_hash) }}
+ </span>
+ <span
+ v-if="contact.lxmf_address"
+ class="text-[9px] text-gray-400 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
+ :title="contact.lxmf_address"
+ @click.stop="copyHash(contact.lxmf_address)"
+ >
+ LXMF: {{ formatDestinationHash(contact.lxmf_address) }}
+ </span>
+ <span
+ v-if="contact.lxst_address"
+ class="text-[9px] text-gray-400 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
+ :title="contact.lxst_address"
+ @click.stop="copyHash(contact.lxst_address)"
+ >
+ LXST: {{ formatDestinationHash(contact.lxst_address) }}
+ </span>
+ </div>
<button
type="button"
- class="text-[10px] bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 px-3 py-1 rounded-full font-bold uppercase tracking-wider hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors"
+ class="text-[10px] bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 px-3 py-1 rounded-full font-bold uppercase tracking-wider hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors shrink-0"
@click="
destinationHash =
contact.remote_telephony_hash ||
@@ -2044,6 +2063,34 @@
placeholder="e.g. a39610c89d18bb48c73e429582423c24"
/>
</div>
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
+ <div>
+ <label
+ class="block text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-wider mb-1.5 ml-1"
+ >
+ {{ $t("app.lxmf_address") }}
+ </label>
+ <input
+ v-model="contactForm.lxmf_address"
+ type="text"
+ class="input-field font-mono text-xs"
+ placeholder="Optional"
+ />
+ </div>
+ <div>
+ <label
+ class="block text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-wider mb-1.5 ml-1"
+ >
+ LXST Address
+ </label>
+ <input
+ v-model="contactForm.lxst_address"
+ type="text"
+ class="input-field font-mono text-xs"
+ placeholder="Optional"
+ />
+ </div>
+ </div>
<div>
<label
class="block text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-wider mb-1.5 ml-1"
@@ -2944,6 +2991,8 @@ export default {
this.contactForm = {
name: "",
remote_identity_hash: "",
+ lxmf_address: "",
+ lxst_address: "",
preferred_ringtone_id: null,
custom_image: null,
};
@@ -2955,6 +3004,8 @@ export default {
id: contact.id,
name: contact.name,
remote_identity_hash: contact.remote_identity_hash,
+ lxmf_address: contact.lxmf_address || "",
+ lxst_address: contact.lxst_address || "",
preferred_ringtone_id: contact.preferred_ringtone_id,
custom_image: contact.custom_image,
};
diff --git a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
index 523b28e8..ce263b91 100644
--- a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
+++ b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
@@ -1076,6 +1076,207 @@
</template>
</ExpandingSection>
+ <ExpandingSection>
+ <template #title>Interface Discovery</template>
+ <template #content>
+ <div class="p-2 space-y-3">
+ <div class="flex items-center">
+ <div class="flex flex-col mr-auto">
+ <FormLabel class="mb-1">Advertise this Interface</FormLabel>
+ <FormSubLabel>
+ Broadcasts connection details so peers can find and connect to this interface.
+ </FormSubLabel>
+ </div>
+ <Toggle v-model="discovery.discoverable" class="my-auto mx-2" />
+ </div>
+
+ <div class="text-sm text-gray-500 dark:text-zinc-300">
+ LXMF must be installed to publish discovery announces. When enabled, Reticulum handles
+ signing, stamping, and periodic announces for this interface.
+ </div>
+
+ <div v-if="discovery.discoverable" class="space-y-3">
+ <div>
+ <FormLabel class="mb-1">Discovery Name</FormLabel>
+ <input
+ v-model="discovery.discovery_name"
+ type="text"
+ placeholder="Human friendly name"
+ class="input-field"
+ />
+ </div>
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
+ <div>
+ <FormLabel class="mb-1">Reachable On</FormLabel>
+ <input
+ v-model="discovery.reachable_on"
+ type="text"
+ placeholder="Hostname, IP, or resolver script path"
+ class="input-field"
+ />
+ </div>
+ <div>
+ <FormLabel class="mb-1">Announce Interval (minutes)</FormLabel>
+ <input
+ v-model.number="discovery.announce_interval"
+ type="number"
+ min="5"
+ class="input-field"
+ />
+ </div>
+ </div>
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
+ <div>
+ <FormLabel class="mb-1">Stamp Value</FormLabel>
+ <input
+ v-model.number="discovery.discovery_stamp_value"
+ type="number"
+ min="1"
+ class="input-field"
+ />
+ </div>
+ <div class="flex items-center">
+ <Toggle id="discovery-encrypt" v-model="discovery.discovery_encrypt" />
+ <FormLabel for="discovery-encrypt" class="ml-2">Encrypt Announces</FormLabel>
+ </div>
+ </div>
+ <div class="flex items-center">
+ <Toggle id="publish-ifac" v-model="discovery.publish_ifac" />
+ <FormLabel for="publish-ifac" class="ml-2">Include IFAC Credentials</FormLabel>
+ </div>
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-3">
+ <div>
+ <FormLabel class="mb-1">Latitude</FormLabel>
+ <input
+ v-model.number="discovery.latitude"
+ type="number"
+ step="0.00001"
+ class="input-field"
+ />
+ </div>
+ <div>
+ <FormLabel class="mb-1">Longitude</FormLabel>
+ <input
+ v-model.number="discovery.longitude"
+ type="number"
+ step="0.00001"
+ class="input-field"
+ />
+ </div>
+ <div>
+ <FormLabel class="mb-1">Height (m)</FormLabel>
+ <input v-model.number="discovery.height" type="number" class="input-field" />
+ </div>
+ </div>
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-3">
+ <div>
+ <FormLabel class="mb-1">Discovery Frequency (Hz)</FormLabel>
+ <input
+ v-model.number="discovery.discovery_frequency"
+ type="number"
+ class="input-field"
+ />
+ </div>
+ <div>
+ <FormLabel class="mb-1">Discovery Bandwidth (Hz)</FormLabel>
+ <input
+ v-model.number="discovery.discovery_bandwidth"
+ type="number"
+ class="input-field"
+ />
+ </div>
+ <div>
+ <FormLabel class="mb-1">Discovery Modulation</FormLabel>
+ <input
+ v-model="discovery.discovery_modulation"
+ type="text"
+ placeholder="e.g. LoRa"
+ class="input-field"
+ />
+ </div>
+ </div>
+ <div class="text-xs text-gray-500 dark:text-zinc-400">
+ If announce encryption is enabled, a valid network identity path is required in the
+ Reticulum configuration.
+ </div>
+ </div>
+ </div>
+ </template>
+ </ExpandingSection>
+
+ <ExpandingSection>
+ <template #title>Discover Interfaces (Peer)</template>
+ <template #content>
+ <div class="p-2 space-y-3">
+ <div class="flex items-center">
+ <div class="flex flex-col mr-auto">
+ <FormLabel class="mb-1">Enable Discovery Listener</FormLabel>
+ <FormSubLabel>
+ Listen for announced interfaces and optionally auto-connect to them.
+ </FormSubLabel>
+ </div>
+ <Toggle v-model="reticulumDiscovery.discover_interfaces" class="my-auto mx-2" />
+ </div>
+
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
+ <div>
+ <FormLabel class="mb-1">Allowed Sources (comma separated)</FormLabel>
+ <input
+ v-model="reticulumDiscovery.interface_discovery_sources"
+ type="text"
+ placeholder="Identity hashes"
+ class="input-field"
+ />
+ </div>
+ <div>
+ <FormLabel class="mb-1">Required Stamp Value</FormLabel>
+ <input
+ v-model.number="reticulumDiscovery.required_discovery_value"
+ type="number"
+ min="0"
+ class="input-field"
+ />
+ </div>
+ <div>
+ <FormLabel class="mb-1">Auto-connect Slots</FormLabel>
+ <input
+ v-model.number="reticulumDiscovery.autoconnect_discovered_interfaces"
+ type="number"
+ min="0"
+ class="input-field"
+ />
+ <FormSubLabel>Set to 0 to disable auto-connect.</FormSubLabel>
+ </div>
+ <div>
+ <FormLabel class="mb-1">Network Identity Path</FormLabel>
+ <input
+ v-model="reticulumDiscovery.network_identity"
+ type="text"
+ placeholder="~/.reticulum/storage/identities/..."
+ class="input-field"
+ />
+ </div>
+ </div>
+
+ <div class="flex justify-end">
+ <button
+ type="button"
+ class="primary-chip text-xs"
+ :disabled="savingDiscovery"
+ @click="saveReticulumDiscoveryConfig"
+ >
+ <MaterialDesignIcon
+ :icon-name="savingDiscovery ? 'progress-clock' : 'content-save'"
+ class="w-4 h-4"
+ :class="{ 'animate-spin-reverse': savingDiscovery }"
+ />
+ <span class="ml-1">Save Discovery Preferences</span>
+ </button>
+ </div>
+ </div>
+ </template>
+ </ExpandingSection>
+
<!-- add/save interface button -->
<div class="p-2 bg-white rounded shadow divide-y divide-gray-200 dark:bg-zinc-900">
<button
@@ -1100,10 +1301,12 @@ import FormLabel from "../forms/FormLabel.vue";
import FormSubLabel from "../forms/FormSubLabel.vue";
import Toggle from "../forms/Toggle.vue";
import GlobalState from "../../js/GlobalState";
+import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: "AddInterfacePage",
components: {
+ MaterialDesignIcon,
FormSubLabel,
FormLabel,
ExpandingSection,
@@ -1147,6 +1350,32 @@ export default {
ifac_size: null,
},
+ discovery: {
+ discoverable: false,
+ discovery_name: "",
+ announce_interval: 360,
+ reachable_on: "",
+ discovery_stamp_value: 14,
+ discovery_encrypt: false,
+ publish_ifac: false,
+ latitude: null,
+ longitude: null,
+ height: null,
+ discovery_frequency: null,
+ discovery_bandwidth: null,
+ discovery_modulation: null,
+ },
+
+ reticulumDiscovery: {
+ discover_interfaces: false,
+ interface_discovery_sources: "",
+ required_discovery_value: null,
+ autoconnect_discovered_interfaces: 0,
+ network_identity: "",
+ },
+
+ savingDiscovery: false,
+
newInterfaceForwardIp: null,
newInterfaceForwardPort: null,
@@ -1250,6 +1479,7 @@ export default {
},
mounted() {
this.getConfig();
+ this.loadReticulumDiscoveryConfig();
this.loadComports();
this.loadCommunityInterfaces();
@@ -1279,6 +1509,66 @@ export default {
console.log(e);
}
},
+ parseBool(value) {
+ if (typeof value === "string") {
+ return ["true", "yes", "1", "y", "on"].includes(value.toLowerCase());
+ }
+ return Boolean(value);
+ },
+ async loadReticulumDiscoveryConfig() {
+ try {
+ const response = await window.axios.get(`/api/v1/reticulum/discovery`);
+ const discovery = response.data?.discovery ?? {};
+ this.reticulumDiscovery.discover_interfaces = this.parseBool(discovery.discover_interfaces);
+ this.reticulumDiscovery.interface_discovery_sources = discovery.interface_discovery_sources ?? "";
+ this.reticulumDiscovery.required_discovery_value =
+ discovery.required_discovery_value !== undefined &&
+ discovery.required_discovery_value !== null &&
+ discovery.required_discovery_value !== ""
+ ? Number(discovery.required_discovery_value)
+ : null;
+ this.reticulumDiscovery.autoconnect_discovered_interfaces =
+ discovery.autoconnect_discovered_interfaces !== undefined &&
+ discovery.autoconnect_discovered_interfaces !== null &&
+ discovery.autoconnect_discovered_interfaces !== ""
+ ? Number(discovery.autoconnect_discovered_interfaces)
+ : 0;
+ this.reticulumDiscovery.network_identity = discovery.network_identity ?? "";
+ } catch (e) {
+ // safe to ignore if discovery config cannot be loaded
+ console.log(e);
+ }
+ },
+ async saveReticulumDiscoveryConfig() {
+ if (this.savingDiscovery) return;
+ this.savingDiscovery = true;
+ try {
+ const payload = {
+ discover_interfaces: this.reticulumDiscovery.discover_interfaces,
+ interface_discovery_sources: this.reticulumDiscovery.interface_discovery_sources || null,
+ required_discovery_value:
+ this.reticulumDiscovery.required_discovery_value === null ||
+ this.reticulumDiscovery.required_discovery_value === ""
+ ? null
+ : Number(this.reticulumDiscovery.required_discovery_value),
+ autoconnect_discovered_interfaces:
+ this.reticulumDiscovery.autoconnect_discovered_interfaces === null ||
+ this.reticulumDiscovery.autoconnect_discovered_interfaces === ""
+ ? 0
+ : Number(this.reticulumDiscovery.autoconnect_discovered_interfaces),
+ network_identity: this.reticulumDiscovery.network_identity || null,
+ };
+
+ await window.axios.patch(`/api/v1/reticulum/discovery`, payload);
+ ToastUtils.success("Discovery settings saved");
+ await this.loadReticulumDiscoveryConfig();
+ } catch (e) {
+ ToastUtils.error("Failed to save discovery settings");
+ console.log(e);
+ } finally {
+ this.savingDiscovery = false;
+ }
+ },
async loadComports() {
try {
const response = await window.axios.get(`/api/v1/comports`);
@@ -1408,6 +1698,22 @@ export default {
this.sharedInterfaceSettings.network_name = iface.network_name;
this.sharedInterfaceSettings.passphrase = iface.passphrase;
this.sharedInterfaceSettings.ifac_size = iface.ifac_size;
+
+ // interface discovery
+ this.discovery.discoverable = this.parseBool(iface.discoverable);
+ this.discovery.discovery_name = iface.discovery_name ?? "";
+ this.discovery.announce_interval = iface.announce_interval ?? this.discovery.announce_interval;
+ this.discovery.reachable_on = iface.reachable_on ?? "";
+ this.discovery.discovery_stamp_value =
+ iface.discovery_stamp_value ?? this.discovery.discovery_stamp_value;
+ this.discovery.discovery_encrypt = this.parseBool(iface.discovery_encrypt);
+ this.discovery.publish_ifac = this.parseBool(iface.publish_ifac);
+ this.discovery.latitude = iface.latitude !== undefined ? Number(iface.latitude) : null;
+ this.discovery.longitude = iface.longitude !== undefined ? Number(iface.longitude) : null;
+ this.discovery.height = iface.height !== undefined ? Number(iface.height) : null;
+ this.discovery.discovery_frequency = iface.discovery_frequency ?? null;
+ this.discovery.discovery_bandwidth = iface.discovery_bandwidth ?? null;
+ this.discovery.discovery_modulation = iface.discovery_modulation ?? null;
} catch {
// do nothing if failed to load interfaces
}
@@ -1430,6 +1736,15 @@ export default {
});
}
+ const discoveryEnabled = this.discovery.discoverable === true;
+ const isRadioInterface = ["RNodeInterface", "RNodeIPInterface"].includes(this.newInterfaceType);
+ const fallbackDiscoveryFrequency =
+ this.discovery.discovery_frequency ??
+ (discoveryEnabled && isRadioInterface ? this.calculateFrequencyInHz() : null);
+ const fallbackDiscoveryBandwidth =
+ this.discovery.discovery_bandwidth ??
+ (discoveryEnabled && isRadioInterface ? this.newInterfaceBandwidth : null);
+
// add interface
const response = await window.axios.post(`/api/v1/reticulum/interfaces/add`, {
allow_overwriting_interface: this.isEditingInterface,
@@ -1506,6 +1821,32 @@ export default {
airtime_limit_long: this.newInterfaceAirtimeLimitLong,
airtime_limit_short: this.newInterfaceAirtimeLimitShort,
+ // discovery options
+ discoverable: discoveryEnabled ? "yes" : null,
+ discovery_name: discoveryEnabled ? this.discovery.discovery_name : null,
+ announce_interval:
+ discoveryEnabled && this.discovery.announce_interval !== null
+ ? Number(this.discovery.announce_interval)
+ : null,
+ reachable_on: discoveryEnabled ? this.discovery.reachable_on : null,
+ discovery_stamp_value:
+ discoveryEnabled && this.discovery.discovery_stamp_value !== null
+ ? Number(this.discovery.discovery_stamp_value)
+ : null,
+ discovery_encrypt: discoveryEnabled ? this.discovery.discovery_encrypt : null,
+ publish_ifac: discoveryEnabled ? this.discovery.publish_ifac : null,
+ latitude:
+ discoveryEnabled && this.discovery.latitude !== null ? Number(this.discovery.latitude) : null,
+ longitude:
+ discoveryEnabled && this.discovery.longitude !== null ? Number(this.discovery.longitude) : null,
+ height: discoveryEnabled && this.discovery.height !== null ? Number(this.discovery.height) : null,
+ discovery_frequency: discoveryEnabled ? fallbackDiscoveryFrequency : null,
+ discovery_bandwidth: discoveryEnabled ? fallbackDiscoveryBandwidth : null,
+ discovery_modulation:
+ discoveryEnabled && this.discovery.discovery_modulation
+ ? this.discovery.discovery_modulation
+ : null,
+
// settings that can be added to any interface type
mode: this.sharedInterfaceSettings.mode || "full",
bitrate: this.sharedInterfaceSettings.bitrate,
diff --git a/meshchatx/src/frontend/components/interfaces/Interface.vue b/meshchatx/src/frontend/components/interfaces/Interface.vue
index a5572e2f..e8473604 100644
--- a/meshchatx/src/frontend/components/interfaces/Interface.vue
+++ b/meshchatx/src/frontend/components/interfaces/Interface.vue
@@ -42,6 +42,7 @@
<span :class="statusChipClass">{{
isInterfaceEnabled(iface) ? $t("app.enabled") : $t("app.disabled")
}}</span>
+ <span v-if="isDiscoverable()" class="discoverable-chip">Discoverable</span>
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ description }}
@@ -244,6 +245,13 @@ export default {
onIFACSignatureClick: function (ifacSignature) {
DialogUtils.alert(ifacSignature);
},
+ isDiscoverable() {
+ const value = this.iface.discoverable;
+ if (typeof value === "string") {
+ return ["true", "yes", "1", "on"].includes(value.toLowerCase());
+ }
+ return Boolean(value);
+ },
isInterfaceEnabled: function (iface) {
return Utils.isInterfaceEnabled(iface);
},
@@ -292,6 +300,9 @@ export default {
.ifac-line {
@apply text-xs flex flex-wrap items-center gap-1;
}
+.discoverable-chip {
+ @apply inline-flex items-center rounded-full bg-blue-100 text-blue-700 px-2 py-0.5 text-xs font-semibold dark:bg-blue-900/50 dark:text-blue-200;
+}
.detail-grid {
@apply grid gap-3 sm:grid-cols-2;
}
diff --git a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
index ee935464..f2e67b3f 100644
--- a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
+++ b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
@@ -120,7 +120,115 @@
<div class="text-sm">{{ $t("interfaces.no_interfaces_description") }}</div>
</div>
- <div v-else class="grid gap-4 xl:grid-cols-2">
+ <div class="glass-card space-y-4">
+ <div class="flex flex-wrap gap-3 items-center">
+ <div class="flex-1">
+ <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
+ Discovery
+ </div>
+ <div class="text-xl font-semibold text-gray-900 dark:text-white">Interface Discovery</div>
+ <div class="text-sm text-gray-600 dark:text-gray-300">
+ Publish your interfaces for others to find, or listen for announced entrypoints and
+ auto-connect to them.
+ </div>
+ </div>
+ <RouterLink :to="{ name: 'interfaces.add' }" class="secondary-chip text-sm">
+ <MaterialDesignIcon icon-name="lan" class="w-4 h-4" />
+ Configure Per-Interface
+ </RouterLink>
+ </div>
+ <div class="grid gap-4 md:grid-cols-2">
+ <div class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
+ <div class="font-semibold text-gray-900 dark:text-white">Publish (Server)</div>
+ <div>
+ Enable discovery while adding or editing an interface to broadcast reachable details.
+ Reticulum will sign and stamp announces automatically.
+ </div>
+ <div class="text-xs text-gray-500 dark:text-gray-400">
+ Requires LXMF in the Python environment. Transport is optional for publishing, but
+ usually recommended so peers can connect back.
+ </div>
+ </div>
+ <div class="space-y-3">
+ <div class="flex items-center">
+ <div class="flex flex-col mr-auto">
+ <div class="text-sm font-semibold text-gray-900 dark:text-white">
+ Discover Interfaces (Peer)
+ </div>
+ <div class="text-xs text-gray-500 dark:text-gray-400">
+ Listen for discovery announces and optionally auto-connect to available
+ interfaces.
+ </div>
+ </div>
+ <Toggle v-model="discoveryConfig.discover_interfaces" class="my-auto mx-2" />
+ </div>
+ <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
+ <div>
+ <div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
+ Allowed Sources
+ </div>
+ <input
+ v-model="discoveryConfig.interface_discovery_sources"
+ type="text"
+ placeholder="Comma separated identity hashes"
+ class="input-field"
+ />
+ </div>
+ <div>
+ <div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
+ Required Stamp Value
+ </div>
+ <input
+ v-model.number="discoveryConfig.required_discovery_value"
+ type="number"
+ min="0"
+ class="input-field"
+ />
+ </div>
+ <div>
+ <div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
+ Auto-connect Slots
+ </div>
+ <input
+ v-model.number="discoveryConfig.autoconnect_discovered_interfaces"
+ type="number"
+ min="0"
+ class="input-field"
+ />
+ <div class="text-xs text-gray-500 dark:text-gray-400">0 disables auto-connect.</div>
+ </div>
+ <div>
+ <div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
+ Network Identity Path
+ </div>
+ <input
+ v-model="discoveryConfig.network_identity"
+ type="text"
+ placeholder="~/.reticulum/storage/identities/..."
+ class="input-field"
+ />
+ </div>
+ </div>
+ <div class="flex justify-end">
+ <button
+ type="button"
+ class="primary-chip text-xs"
+ :disabled="savingDiscovery"
+ @click="saveDiscoveryConfig"
+ >
+ <MaterialDesignIcon
+ :icon-name="savingDiscovery ? 'progress-clock' : 'content-save'"
+ class="w-4 h-4"
+ :class="{ 'animate-spin-reverse': savingDiscovery }"
+ />
+ <span class="ml-1">Save Discovery Settings</span>
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div v-if="filteredInterfaces.length !== 0" class="grid gap-4 xl:grid-cols-2">
<Interface
v-for="iface of filteredInterfaces"
:key="iface._name"
@@ -150,10 +258,12 @@ import DownloadUtils from "../../js/DownloadUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ToastUtils from "../../js/ToastUtils";
import GlobalState from "../../js/GlobalState";
+import Toggle from "../forms/Toggle.vue";
export default {
name: "InterfacesPage",
components: {
+ Toggle,
ImportInterfacesModal,
Interface,
MaterialDesignIcon,
@@ -168,6 +278,14 @@ export default {
typeFilter: "all",
reloadingRns: false,
isReticulumRunning: true,
+ discoveryConfig: {
+ discover_interfaces: false,
+ interface_discovery_sources: "",
+ required_discovery_value: null,
+ autoconnect_discovered_interfaces: 0,
+ network_identity: "",
+ },
+ savingDiscovery: false,
};
},
computed: {
@@ -246,6 +364,7 @@ export default {
mounted() {
this.loadInterfaces();
this.updateInterfaceStats();
+ this.loadDiscoveryConfig();
// update info every few seconds
this.reloadInterval = setInterval(() => {
@@ -387,6 +506,65 @@ export default {
this.trackInterfaceChange();
}
},
+ parseBool(value) {
+ if (typeof value === "string") {
+ return ["true", "yes", "1", "y", "on"].includes(value.toLowerCase());
+ }
+ return Boolean(value);
+ },
+ async loadDiscoveryConfig() {
+ try {
+ const response = await window.axios.get(`/api/v1/reticulum/discovery`);
+ const discovery = response.data?.discovery ?? {};
+ this.discoveryConfig.discover_interfaces = this.parseBool(discovery.discover_interfaces);
+ this.discoveryConfig.interface_discovery_sources = discovery.interface_discovery_sources ?? "";
+ this.discoveryConfig.required_discovery_value =
+ discovery.required_discovery_value !== undefined &&
+ discovery.required_discovery_value !== null &&
+ discovery.required_discovery_value !== ""
+ ? Number(discovery.required_discovery_value)
+ : null;
+ this.discoveryConfig.autoconnect_discovered_interfaces =
+ discovery.autoconnect_discovered_interfaces !== undefined &&
+ discovery.autoconnect_discovered_interfaces !== null &&
+ discovery.autoconnect_discovered_interfaces !== ""
+ ? Number(discovery.autoconnect_discovered_interfaces)
+ : 0;
+ this.discoveryConfig.network_identity = discovery.network_identity ?? "";
+ } catch (e) {
+ console.log(e);
+ }
+ },
+ async saveDiscoveryConfig() {
+ if (this.savingDiscovery) return;
+ this.savingDiscovery = true;
+ try {
+ const payload = {
+ discover_interfaces: this.discoveryConfig.discover_interfaces,
+ interface_discovery_sources: this.discoveryConfig.interface_discovery_sources || null,
+ required_discovery_value:
+ this.discoveryConfig.required_discovery_value === null ||
+ this.discoveryConfig.required_discovery_value === ""
+ ? null
+ : Number(this.discoveryConfig.required_discovery_value),
+ autoconnect_discovered_interfaces:
+ this.discoveryConfig.autoconnect_discovered_interfaces === null ||
+ this.discoveryConfig.autoconnect_discovered_interfaces === ""
+ ? 0
+ : Number(this.discoveryConfig.autoconnect_discovered_interfaces),
+ network_identity: this.discoveryConfig.network_identity || null,
+ };
+
+ await window.axios.patch(`/api/v1/reticulum/discovery`, payload);
+ ToastUtils.success("Discovery settings saved");
+ await this.loadDiscoveryConfig();
+ } catch (e) {
+ ToastUtils.error("Failed to save discovery settings");
+ console.log(e);
+ } finally {
+ this.savingDiscovery = false;
+ }
+ },
setStatusFilter(value) {
this.statusFilter = value;
},
diff --git a/meshchatx/src/frontend/components/map/MapPage.vue b/meshchatx/src/frontend/components/map/MapPage.vue
index ce631098..85c72196 100644
--- a/meshchatx/src/frontend/components/map/MapPage.vue
+++ b/meshchatx/src/frontend/components/map/MapPage.vue
@@ -116,6 +116,22 @@
>
<v-icon icon="mdi-trash-can-outline" size="18" class="sm:!size-5"></v-icon>
</button>
+ <button
+ v-if="selectedFeature"
+ class="p-1.5 sm:p-2 rounded-xl bg-blue-100 dark:bg-blue-900/30 text-blue-600 transition-all hover:scale-110 active:scale-90"
+ title="Edit note"
+ @click="startEditingNote(selectedFeature)"
+ >
+ <v-icon icon="mdi-note-edit-outline" size="18" class="sm:!size-5"></v-icon>
+ </button>
+ <button
+ v-if="selectedFeature && !selectedFeature.get('telemetry')"
+ class="p-1.5 sm:p-2 rounded-xl bg-red-100 dark:bg-red-900/30 text-red-600 transition-all hover:scale-110 active:scale-90 animate-pulse"
+ title="Delete selected item"
+ @click="deleteSelectedFeature"
+ >
+ <v-icon icon="mdi-selection-remove" size="18" class="sm:!size-5"></v-icon>
+ </button>
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-0.5 sm:mx-1"></div>
<button
class="p-1.5 sm:p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
@@ -211,18 +227,27 @@
<!-- note hover tooltip -->
<div
- v-if="hoveredNote && !editingFeature"
+ v-if="
+ hoveredFeature &&
+ (hoveredFeature.get('note') ||
+ (hoveredFeature.get('telemetry') && hoveredFeature.get('telemetry').note)) &&
+ !editingFeature
+ "
class="absolute pointer-events-none z-50 bg-white/90 dark:bg-zinc-900/90 backdrop-blur border border-gray-200 dark:border-zinc-700 rounded-lg shadow-xl p-2 text-sm text-gray-900 dark:text-zinc-100 max-w-xs transform -translate-x-1/2 -translate-y-full mb-4"
:style="{
- left: map.getPixelFromCoordinate(hoveredNote.getGeometry().getCoordinates())[0] + 'px',
- top: map.getPixelFromCoordinate(hoveredNote.getGeometry().getCoordinates())[1] + 'px',
+ left: map.getPixelFromCoordinate(hoveredFeature.getGeometry().getCoordinates())[0] + 'px',
+ top: map.getPixelFromCoordinate(hoveredFeature.getGeometry().getCoordinates())[1] + 'px',
}"
>
<div class="font-bold flex items-center gap-1 mb-1 text-amber-500">
<MaterialDesignIcon icon-name="note-text" class="size-4" />
- <span>Note</span>
+ <span>{{
+ hoveredFeature.get("telemetry") ? hoveredFeature.get("peer")?.display_name || "Peer" : "Note"
+ }}</span>
+ </div>
+ <div class="whitespace-pre-wrap break-words">
+ {{ hoveredFeature.get("note") || hoveredFeature.get("telemetry")?.note }}
</div>
- <div class="whitespace-pre-wrap break-words">{{ hoveredNote.get("note") || "Empty note" }}</div>
</div>
<!-- inline note editor (overlay) -->
@@ -266,6 +291,58 @@
</div>
</div>
+ <!-- context menu -->
+ <div
+ v-if="showContextMenu"
+ class="fixed z-[120] bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-xl shadow-2xl overflow-hidden text-sm text-gray-900 dark:text-zinc-100"
+ :style="{ left: contextMenuPos.x + 'px', top: contextMenuPos.y + 'px' }"
+ >
+ <div class="px-3 py-2 font-bold border-b border-gray-100 dark:border-zinc-800">
+ {{ contextMenuFeature ? "Feature actions" : "Map actions" }}
+ </div>
+ <div class="flex flex-col">
+ <button
+ v-if="contextMenuFeature"
+ class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
+ @click="contextSelectFeature"
+ >
+ <MaterialDesignIcon icon-name="cursor-default" class="size-4" />
+ <span>Select / Move</span>
+ </button>
+ <button
+ v-if="contextMenuFeature"
+ class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
+ @click="contextAddNote"
+ >
+ <MaterialDesignIcon icon-name="note-edit" class="size-4" />
+ <span>Add / Edit Note</span>
+ </button>
+ <button
+ v-if="contextMenuFeature && !contextMenuFeature.get('telemetry')"
+ class="flex items-center gap-2 px-3 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 text-left text-red-600"
+ @click="contextDeleteFeature"
+ >
+ <MaterialDesignIcon icon-name="delete" class="size-4" />
+ <span>Delete</span>
+ </button>
+ <button
+ class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
+ @click="contextCopyCoords"
+ >
+ <MaterialDesignIcon icon-name="crosshairs-gps" class="size-4" />
+ <span>Copy coords</span>
+ </button>
+ <button
+ v-if="!contextMenuFeature"
+ class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
+ @click="contextClearMap"
+ >
+ <MaterialDesignIcon icon-name="delete-sweep" class="size-4" />
+ <span>Clear drawings</span>
+ </button>
+ </div>
+ </div>
+
<!-- loading skeleton for map -->
<div v-if="!isMapLoaded" class="absolute inset-0 z-0 bg-slate-100 dark:bg-zinc-900 animate-pulse">
<div class="grid grid-cols-4 grid-rows-4 h-full w-full gap-1 p-1 opacity-20">
@@ -550,11 +627,11 @@
>
<div class="flex justify-between space-x-4">
<span class="opacity-50 uppercase tracking-tighter">Lat</span>
- <span class="text-gray-900 dark:text-zinc-100">{{ currentCenter[1].toFixed(6) }}</span>
+ <span class="text-gray-900 dark:text-zinc-100">{{ displayCoords[1].toFixed(6) }}</span>
</div>
<div class="flex justify-between space-x-4">
<span class="opacity-50 uppercase tracking-tighter">Lon</span>
- <span class="text-gray-900 dark:text-zinc-100">{{ currentCenter[0].toFixed(6) }}</span>
+ <span class="text-gray-900 dark:text-zinc-100">{{ displayCoords[0].toFixed(6) }}</span>
</div>
</div>
</div>
@@ -710,11 +787,11 @@
</div>
<div class="flex justify-between">
<span>Lat:</span>
- <span class="font-mono">{{ currentCenter[1].toFixed(5) }}</span>
+ <span class="font-mono">{{ displayCoords[1].toFixed(5) }}</span>
</div>
<div class="flex justify-between">
<span>Lon:</span>
- <span class="font-mono">{{ currentCenter[0].toFixed(5) }}</span>
+ <span class="font-mono">{{ displayCoords[0].toFixed(5) }}</span>
</div>
</div>
</div>
@@ -967,15 +1044,18 @@ import XYZ from "ol/source/XYZ";
import VectorSource from "ol/source/Vector";
import Feature from "ol/Feature";
import Point from "ol/geom/Point";
-import { Style, Text, Fill, Stroke, Circle as CircleStyle } from "ol/style";
+import { Style, Text, Fill, Stroke, Circle as CircleStyle, Icon } from "ol/style";
import { fromLonLat, toLonLat } from "ol/proj";
import { defaults as defaultControls } from "ol/control";
import DragBox from "ol/interaction/DragBox";
import Draw from "ol/interaction/Draw";
import Modify from "ol/interaction/Modify";
import Snap from "ol/interaction/Snap";
+import Select from "ol/interaction/Select";
+import Translate from "ol/interaction/Translate";
import { getArea, getLength } from "ol/sphere";
-import { LineString, Polygon } from "ol/geom";
+import { LineString, Polygon, Circle } from "ol/geom";
+import { fromCircle } from "ol/geom/Polygon";
import { unByKey } from "ol/Observable";
import Overlay from "ol/Overlay";
import GeoJSON from "ol/format/GeoJSON";
@@ -1001,6 +1081,7 @@ export default {
isSettingsOpen: false,
currentCenter: [0, 0],
currentZoom: 2,
+ cursorCoords: null,
config: null,
peers: {},
@@ -1059,8 +1140,8 @@ export default {
drawType: null, // 'Point', 'LineString', 'Polygon', 'Circle' or null
isDrawing: false,
drawingTools: [
+ { type: "Select", icon: "cursor-default" },
{ type: "Point", icon: "map-marker-plus" },
- { type: "Note", icon: "note-text-outline" },
{ type: "LineString", icon: "vector-line" },
{ type: "Polygon", icon: "vector-polygon" },
{ type: "Circle", icon: "circle-outline" },
@@ -1074,6 +1155,7 @@ export default {
helpTooltip: null,
measureTooltipElement: null,
measureTooltip: null,
+ measurementOverlays: [],
// drawing storage
savedDrawings: [],
@@ -1081,13 +1163,22 @@ export default {
// note editing
editingFeature: null,
noteText: "",
- hoveredNote: null,
+ hoveredFeature: null,
noteOverlay: null,
showNoteModal: false,
showSaveDrawingModal: false,
newDrawingName: "",
isLoadingDrawings: false,
showLoadDrawingModal: false,
+ styleCache: {},
+ selectedFeature: null,
+ select: null,
+ translate: null,
+ // context menu
+ showContextMenu: false,
+ contextMenuPos: { x: 0, y: 0 },
+ contextMenuFeature: null,
+ contextMenuCoord: null,
};
},
computed: {
@@ -1104,6 +1195,9 @@ export default {
}
return total;
},
+ displayCoords() {
+ return this.cursorCoords || this.currentCenter;
+ },
},
watch: {
showSaveDrawingModal(val) {
@@ -1148,7 +1242,9 @@ export default {
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
});
+ console.log("Restoring persisted drawings, count:", features.length);
this.drawSource.addFeatures(features);
+ this.rebuildMeasurementOverlays();
} catch (e) {
console.error("Failed to restore persisted drawings", e);
}
@@ -1201,6 +1297,19 @@ export default {
}, 30000);
},
beforeUnmount() {
+ if (this.map && this.map.getViewport()) {
+ this.map.getViewport().removeEventListener("contextmenu", this.onContextMenu);
+ }
+ document.removeEventListener("click", this.handleGlobalClick);
+ if (this._saveStateTimer) {
+ clearTimeout(this._saveStateTimer);
+ this._saveStateTimer = null;
+ }
+ if (this._pendingSaveResolvers && this._pendingSaveResolvers.length > 0) {
+ const pending = this._pendingSaveResolvers.slice();
+ this._pendingSaveResolvers = [];
+ this.saveMapStateImmediate().then(() => pending.forEach((p) => p.resolve()));
+ }
if (this.reloadInterval) clearInterval(this.reloadInterval);
if (this.exportInterval) clearInterval(this.exportInterval);
if (this.searchTimeout) clearTimeout(this.searchTimeout);
@@ -1209,17 +1318,37 @@ export default {
WebSocketConnection.off("message", this.onWebsocketMessage);
},
methods: {
- async saveMapState() {
+ saveMapState() {
+ if (!this._pendingSaveResolvers) {
+ this._pendingSaveResolvers = [];
+ }
+ return new Promise((resolve, reject) => {
+ this._pendingSaveResolvers.push({ resolve, reject });
+ if (this._saveStateTimer) clearTimeout(this._saveStateTimer);
+ this._saveStateTimer = setTimeout(async () => {
+ const pending = this._pendingSaveResolvers.slice();
+ this._pendingSaveResolvers = [];
+ this._saveStateTimer = null;
+ try {
+ await this.saveMapStateImmediate();
+ pending.forEach((p) => p.resolve());
+ } catch (e) {
+ pending.forEach((p) => p.reject(e));
+ }
+ }, 150);
+ });
+ },
+ async saveMapStateImmediate() {
try {
- // Serialize drawings
let drawings = null;
if (this.drawSource) {
const format = new GeoJSON();
- drawings = format.writeFeatures(this.drawSource.getFeatures());
+ const features = this.serializeFeatures(this.drawSource.getFeatures());
+ drawings = format.writeFeatures(features, {
+ dataProjection: "EPSG:4326",
+ featureProjection: "EPSG:3857",
+ });
}
-
- // Use JSON.parse/stringify to strip Vue Proxies and ensure plain objects/arrays
- // This prevents DataCloneError when saving to IndexedDB
const state = JSON.parse(
JSON.stringify({
center: this.currentCenter,
@@ -1231,6 +1360,7 @@ export default {
})
);
await TileCache.setMapState("last_view", state);
+ console.log("Map state persisted to cache, drawings size:", drawings ? drawings.length : 0);
} catch (e) {
console.error("Failed to save map state", e);
}
@@ -1340,20 +1470,19 @@ export default {
source: this.drawSource,
style: (feature) => {
const type = feature.get("type");
- if (type === "note") {
- return new Style({
- image: new CircleStyle({
- radius: 10,
- fill: new Fill({
- color: "#f59e0b",
- }),
- stroke: new Stroke({
- color: "#ffffff",
- width: 2,
- }),
- }),
- // Use a simple circle for now as custom fonts in canvas can be tricky
- // or use the built-in Text style if we are sure it works
+ const geometry = feature.getGeometry();
+ const geomType = geometry ? geometry.getType() : null;
+
+ if (type === "note" || geomType === "Point") {
+ const isNote = type === "note";
+ return this.createMarkerStyle({
+ iconColor: isNote ? "#f59e0b" : "#3b82f6",
+ bgColor: "#ffffff",
+ label: isNote && feature.get("note") ? "Note" : "",
+ isStale: false,
+ iconPath: isNote
+ ? "M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"
+ : null,
});
}
return new Style({
@@ -1375,6 +1504,7 @@ export default {
zIndex: 50,
});
this.map.addLayer(this.drawLayer);
+ this.attachDrawPersistence();
this.noteOverlay = new Overlay({
element: this.$refs.noteOverlayElement,
@@ -1387,16 +1517,83 @@ export default {
this.map.addOverlay(this.noteOverlay);
this.modify = new Modify({ source: this.drawSource });
- this.modify.on("modifyend", () => this.saveMapState());
+ this.modify.on("modifystart", (e) => {
+ const feats = (e.features && e.features.getArray()) || this.select.getFeatures().getArray();
+ feats.forEach((f) => this.clearMeasurementOverlay(f));
+ });
+ this.modify.on("modifyend", (e) => {
+ const feats = (e.features && e.features.getArray()) || this.select.getFeatures().getArray();
+ feats.forEach((f) => this.finalizeMeasurementOverlay(f));
+ this.saveMapState();
+ });
this.map.addInteraction(this.modify);
+ this.select = new Select({
+ layers: [this.drawLayer],
+ hitTolerance: 15, // High tolerance for touch/offgrid
+ style: null, // Keep original feature style
+ });
+ this.select.on("select", (e) => {
+ this.selectedFeature = e.selected[0] || null;
+ });
+ this.map.addInteraction(this.select);
+
+ this.translate = new Translate({
+ features: this.select.getFeatures(),
+ layers: [this.drawLayer], // Only move drawing layer items, not telemetry
+ });
+ this.translate.on("translateend", (e) => {
+ const feats = (e.features && e.features.getArray()) || this.select.getFeatures().getArray();
+ feats.forEach((f) => this.finalizeMeasurementOverlay(f));
+ this.saveMapState();
+ });
+ this.map.addInteraction(this.translate);
+
+ // Default to Select tool
+ this.drawType = "Select";
+ this.select.setActive(true);
+ this.translate.setActive(true);
+ this.modify.setActive(true);
+
this.snap = new Snap({ source: this.drawSource });
this.map.addInteraction(this.snap);
+ // Right-click context menu
+ this.map.getViewport().addEventListener("contextmenu", this.onContextMenu);
+
// setup telemetry markers
this.markerSource = new VectorSource();
this.markerLayer = new VectorLayer({
source: this.markerSource,
+ style: (feature) => {
+ const t = feature.get("telemetry");
+ const peer = feature.get("peer");
+ const displayName = peer?.display_name || t.destination_hash.substring(0, 8);
+
+ // Calculate staleness
+ const now = Date.now();
+ const updatedAt = t.updated_at
+ ? new Date(t.updated_at).getTime()
+ : t.timestamp
+ ? t.timestamp * 1000
+ : now;
+ const isStale = now - updatedAt > 10 * 60 * 1000;
+
+ let iconColor = "#2563eb";
+ let bgColor = "#ffffff";
+
+ if (peer?.lxmf_user_icon) {
+ iconColor = peer.lxmf_user_icon.foreground_colour || iconColor;
+ bgColor = peer.lxmf_user_icon.background_colour || bgColor;
+ }
+
+ return this.createMarkerStyle({
+ iconColor,
+ bgColor,
+ label: displayName,
+ isStale,
+ });
+ },
zIndex: 100,
});
this.map.addLayer(this.markerLayer);
@@ -1404,12 +1601,19 @@ export default {
this.map.on("pointermove", this.handleMapPointerMove);
this.map.on("click", (evt) => {
this.handleMapClick(evt);
+ this.closeContextMenu();
const feature = this.map.forEachFeatureAtPixel(evt.pixel, (f) => f);
if (feature && feature.get("telemetry")) {
this.onMarkerClick(feature);
} else {
this.selectedMarker = null;
}
+
+ // Deselect drawing if clicking empty space
+ if (!feature && this.select) {
+ this.select.getFeatures().clear();
+ this.selectedFeature = null;
+ }
});
this.currentCenter = [defaultLon, defaultLat];
@@ -1431,6 +1635,9 @@ export default {
this.map.addInteraction(this.dragBox);
this.isMapLoaded = true;
+
+ // Close context menu when clicking elsewhere
+ document.addEventListener("click", this.handleGlobalClick);
},
isLocalUrl(url) {
if (!url) return false;
@@ -2057,10 +2264,29 @@ export default {
}
},
+ attachDrawPersistence() {
+ if (!this.drawSource) return;
+ const persist = () => this.saveMapState();
+ this.drawSource.on("addfeature", persist);
+ this.drawSource.on("removefeature", persist);
+ this.drawSource.on("changefeature", persist);
+ this.drawSource.on("clear", persist);
+ },
+
+ deleteSelectedFeature() {
+ if (this.selectedFeature && this.drawSource) {
+ this.clearMeasurementOverlay(this.selectedFeature);
+ this.drawSource.removeFeature(this.selectedFeature);
+ if (this.select) this.select.getFeatures().clear();
+ this.selectedFeature = null;
+ this.saveMapState();
+ }
+ },
+
// Drawing methods
toggleDraw(type) {
if (!this.map) return;
- if (this.drawType === type && !this.isMeasuring) {
+ if (this.drawType === type && !this.isDrawing) {
this.stopDrawing();
return;
}
@@ -2069,27 +2295,79 @@ export default {
this.isMeasuring = false;
this.drawType = type;
+ if (type === "Select") {
+ if (this.select) this.select.setActive(true);
+ if (this.translate) this.translate.setActive(true);
+ if (this.modify) this.modify.setActive(true);
+ return;
+ }
+
+ // Disable selection/translation while drawing
+ if (this.select) this.select.setActive(false);
+ if (this.translate) this.translate.setActive(false);
+ if (this.modify) this.modify.setActive(false);
+
this.draw = new Draw({
source: this.drawSource,
- type: type === "Note" ? "Point" : type,
+ type: type,
});
- this.draw.on("drawstart", () => {
+ this.draw.on("drawstart", (evt) => {
this.isDrawing = true;
+ this.sketch = evt.feature;
+
+ // For LineString, Polygon, and Circle, show measure tooltip while drawing
+ if (type === "LineString" || type === "Polygon" || type === "Circle") {
+ this.createMeasureTooltip();
+ this._drawListener = this.sketch.getGeometry().on("change", (e) => {
+ const geom = e.target;
+ let output;
+ let tooltipCoord;
+ if (geom instanceof Polygon) {
+ output = this.formatArea(geom);
+ tooltipCoord = geom.getInteriorPoint().getCoordinates();
+ } else if (geom instanceof LineString) {
+ output = this.formatLength(geom);
+ tooltipCoord = geom.getLastCoordinate();
+ } else if (geom instanceof Circle) {
+ const radius = geom.getRadius();
+ const center = geom.getCenter();
+ // Calculate radius distance in projection (sphere-aware)
+ const edge = [center[0] + radius, center[1]];
+ const line = new LineString([center, edge]);
+ output = `Radius: ${this.formatLength(line)}`;
+ tooltipCoord = edge;
+ }
+ if (output) {
+ this.measureTooltipElement.innerHTML = output;
+ this.measureTooltip.setPosition(tooltipCoord);
+ }
+ });
+ }
});
this.draw.on("drawend", (evt) => {
this.isDrawing = false;
const feature = evt.feature;
- if (type === "Note") {
- feature.set("type", "note");
- feature.set("note", "");
- // Open edit box after a short delay to let the feature settle
- setTimeout(() => {
- this.startEditingNote(feature);
- }, 200);
+ feature.set("type", "draw"); // Tag as custom drawing for styling
+
+ // Clean up sketch listener and tooltips unless it was the Measure tool
+ if (this._drawListener) {
+ unByKey(this._drawListener);
+ this._drawListener = null;
}
- // Use setTimeout to ensure the feature is actually in the source before saving
+ this.sketch = null;
+
+ // Finalize measurement overlay for the drawn feature
+ this.finalizeMeasurementOverlay(feature);
+ this.cleanupMeasureTooltip();
+
+ // Re-enable select/translate/modify after drawing
+ if (this.select) this.select.setActive(true);
+ if (this.translate) this.translate.setActive(true);
+ if (this.modify) this.modify.setActive(true);
+ this.drawType = "Select";
+
setTimeout(() => this.saveMapState(), 100);
});
@@ -2098,7 +2376,8 @@ export default {
startEditingNote(feature) {
this.editingFeature = feature;
- this.noteText = feature.get("note") || "";
+ const telemetry = feature.get("telemetry");
+ this.noteText = telemetry ? telemetry.note || "" : feature.get("note") || "";
if (this.isMobileScreen) {
this.showNoteModal = true;
} else {
@@ -2109,13 +2388,29 @@ export default {
updateNoteOverlay() {
if (!this.editingFeature || !this.map) return;
const geometry = this.editingFeature.getGeometry();
- const coord = geometry.getCoordinates();
+ let coord;
+ if (geometry instanceof Point) {
+ coord = geometry.getCoordinates();
+ } else if (geometry instanceof LineString) {
+ coord = geometry.getCoordinateAt(0.5); // Middle of line
+ } else if (geometry instanceof Polygon) {
+ coord = geometry.getInteriorPoint().getCoordinates();
+ } else if (geometry instanceof Circle) {
+ coord = geometry.getCenter();
+ } else {
+ coord = this.map.getView().getCenter();
+ }
this.noteOverlay.setPosition(coord);
},
saveNote() {
if (this.editingFeature) {
- this.editingFeature.set("note", this.noteText);
+ const telemetry = this.editingFeature.get("telemetry");
+ if (telemetry) {
+ telemetry.note = this.noteText;
+ } else {
+ this.editingFeature.set("note", this.noteText);
+ }
this.saveMapState();
}
this.closeNoteEditor();
@@ -2144,19 +2439,190 @@ export default {
this.closeNoteEditor();
},
+ // Measurement helpers
+ cleanupMeasureTooltip() {
+ if (this.measureTooltipElement && this.measureTooltipElement.parentNode) {
+ this.measureTooltipElement.parentNode.removeChild(this.measureTooltipElement);
+ }
+ if (this.measureTooltip) {
+ this.map.removeOverlay(this.measureTooltip);
+ }
+ this.measureTooltipElement = null;
+ this.measureTooltip = null;
+ },
+ getMeasurementForGeometry(geom) {
+ if (geom instanceof Polygon) {
+ return {
+ text: this.formatArea(geom),
+ coord: geom.getInteriorPoint().getCoordinates(),
+ };
+ }
+ if (geom instanceof LineString) {
+ return {
+ text: this.formatLength(geom),
+ coord: geom.getLastCoordinate(),
+ };
+ }
+ if (geom instanceof Circle) {
+ const center = geom.getCenter();
+ const edge = [center[0] + geom.getRadius(), center[1]];
+ const line = new LineString([center, edge]);
+ return {
+ text: `Radius: ${this.formatLength(line)}`,
+ coord: edge,
+ };
+ }
+ return null;
+ },
+ clearMeasurementOverlay(feature) {
+ const overlay = feature.get("_measureOverlay");
+ if (overlay) {
+ this.map.removeOverlay(overlay);
+ feature.unset("_measureOverlay", true);
+ }
+ },
+ finalizeMeasurementOverlay(feature) {
+ if (!this.map) return;
+ this.clearMeasurementOverlay(feature);
+ const geom = feature.getGeometry();
+ const measurement = this.getMeasurementForGeometry(geom);
+ if (!measurement) return;
+ const el = document.createElement("div");
+ el.className = "ol-tooltip ol-tooltip-static";
+ el.innerHTML = measurement.text;
+ const overlay = new Overlay({
+ element: el,
+ offset: [0, -7],
+ positioning: "bottom-center",
+ });
+ overlay.set("isMeasureTooltip", true);
+ this.map.addOverlay(overlay);
+ overlay.setPosition(measurement.coord);
+ feature.set("_measureOverlay", overlay);
+ },
+ rebuildMeasurementOverlays() {
+ if (!this.drawSource || !this.map) return;
+ // Remove all existing measure overlays
+ const overlays = this.map.getOverlays().getArray();
+ for (let i = overlays.length - 1; i >= 0; i--) {
+ const ov = overlays[i];
+ if (ov.get && ov.get("isMeasureTooltip")) {
+ this.map.removeOverlay(ov);
+ }
+ }
+ // Rebuild for all features
+ this.drawSource.getFeatures().forEach((f) => {
+ f.unset("_measureOverlay", true);
+ this.finalizeMeasurementOverlay(f);
+ });
+ },
+ serializeFeatures(features) {
+ return features.map((f) => {
+ const clone = f.clone();
+ clone.unset("_measureOverlay", true); // avoid circular refs
+ const geom = clone.getGeometry();
+ if (geom instanceof Circle) {
+ clone.setGeometry(fromCircle(geom, 128));
+ }
+ return clone;
+ });
+ },
+ // Context menu handlers
+ onContextMenu(evt) {
+ if (!this.map) return;
+ evt.preventDefault();
+ const pixel = this.map.getEventPixel(evt);
+ const feature = this.map.forEachFeatureAtPixel(pixel, (f) => f);
+ this.contextMenuFeature = feature || null;
+ this.contextMenuCoord = toLonLat(this.map.getCoordinateFromPixel(pixel));
+ this.contextMenuPos = { x: evt.clientX, y: evt.clientY };
+ if (feature && this.select) {
+ this.select.getFeatures().clear();
+ this.select.getFeatures().push(feature);
+ this.selectedFeature = feature;
+ }
+ this.showContextMenu = true;
+ },
+ closeContextMenu() {
+ this.showContextMenu = false;
+ },
+ contextSelectFeature() {
+ if (!this.contextMenuFeature || !this.select || !this.translate) {
+ this.closeContextMenu();
+ return;
+ }
+ this.select.setActive(true);
+ this.translate.setActive(true);
+ this.modify?.setActive(true);
+ this.select.getFeatures().clear();
+ this.select.getFeatures().push(this.contextMenuFeature);
+ this.selectedFeature = this.contextMenuFeature;
+ this.drawType = "Select";
+ this.closeContextMenu();
+ },
+ contextDeleteFeature() {
+ if (this.contextMenuFeature && !this.contextMenuFeature.get("telemetry")) {
+ this.drawSource.removeFeature(this.contextMenuFeature);
+ this.saveMapState();
+ }
+ this.closeContextMenu();
+ },
+ contextAddNote() {
+ if (this.contextMenuFeature) {
+ this.startEditingNote(this.contextMenuFeature);
+ }
+ this.closeContextMenu();
+ },
+ async contextCopyCoords() {
+ if (!this.contextMenuCoord) {
+ this.closeContextMenu();
+ return;
+ }
+ const [lon, lat] = this.contextMenuCoord;
+ const text = `${lat.toFixed(6)}, ${lon.toFixed(6)}`;
+ try {
+ if (navigator?.clipboard?.writeText) {
+ await navigator.clipboard.writeText(text);
+ ToastUtils.success("Copied coordinates");
+ } else {
+ ToastUtils.success(text);
+ }
+ } catch (e) {
+ console.error("Copy failed", e);
+ ToastUtils.warning(text);
+ }
+ this.closeContextMenu();
+ },
+ contextClearMap() {
+ this.clearDrawings();
+ this.closeContextMenu();
+ },
+ // Clear all overlays on escape/context close
+ handleGlobalClick() {
+ if (this.showContextMenu) {
+ this.closeContextMenu();
+ }
+ },
+
handleMapPointerMove(evt) {
+ if (!this.map) return;
+ const lonLat = toLonLat(evt.coordinate);
+ this.cursorCoords = [lonLat[0], lonLat[1]];
if (evt.dragging || this.isDrawing || this.isMeasuring) return;
const pixel = this.map.getEventPixel(evt.originalEvent);
- const feature = this.map.forEachFeatureAtPixel(pixel, (f) => f, {
- layerFilter: (l) => l === this.drawLayer,
- });
+ const feature = this.map.forEachFeatureAtPixel(pixel, (f) => f);
- if (feature && feature.get("type") === "note") {
- this.hoveredNote = feature;
+ if (feature) {
+ const hasNote = feature.get("note") || (feature.get("telemetry") && feature.get("telemetry").note);
+ if (hasNote) {
+ this.hoveredFeature = feature;
+ } else {
+ this.hoveredFeature = null;
+ }
this.map.getTargetElement().style.cursor = "pointer";
} else {
- this.hoveredNote = null;
+ this.hoveredFeature = null;
this.map.getTargetElement().style.cursor = "";
}
},
@@ -2181,6 +2647,9 @@ export default {
this.map.removeInteraction(this.draw);
this.draw = null;
}
+ if (this.select) this.select.setActive(true);
+ if (this.translate) this.translate.setActive(true);
+ if (this.modify) this.modify.setActive(true);
this.drawType = null;
this.isDrawing = false;
this.stopMeasuring();
@@ -2402,8 +2871,11 @@ export default {
}
const format = new GeoJSON();
- const features = this.drawSource.getFeatures();
- const json = format.writeFeatures(features);
+ const features = this.serializeFeatures(this.drawSource.getFeatures());
+ const json = format.writeFeatures(features, {
+ dataProjection: "EPSG:4326",
+ featureProjection: "EPSG:3857",
+ });
try {
await window.axios.post("/api/v1/map/drawings", {
@@ -2426,6 +2898,7 @@ export default {
});
this.drawSource.clear();
this.drawSource.addFeatures(features);
+ await this.saveMapState();
this.showLoadDrawingModal = false;
ToastUtils.success(`Loaded "${drawing.name}"`);
},
@@ -2493,43 +2966,46 @@ export default {
const loc = t.telemetry?.location;
if (!loc || loc.latitude === undefined || loc.longitude === undefined) continue;
- const peer = this.peers[t.destination_hash];
- const displayName = peer?.display_name || t.destination_hash.substring(0, 8);
-
const feature = new Feature({
geometry: new Point(fromLonLat([loc.longitude, loc.latitude])),
telemetry: t,
- peer: peer,
+ peer: this.peers[t.destination_hash],
});
- // Default style
- let iconColor = "#3b82f6";
- let bgColor = "#ffffff";
+ this.markerSource.addFeature(feature);
+ }
+ },
+ createMarkerStyle({ iconColor, bgColor, label, isStale, iconPath }) {
+ const cacheKey = `${iconColor}-${bgColor}-${label}-${isStale}-${iconPath || "default"}`;
+ if (this.styleCache[cacheKey]) return this.styleCache[cacheKey];
- if (peer?.lxmf_user_icon) {
- iconColor = peer.lxmf_user_icon.foreground_colour || iconColor;
- bgColor = peer.lxmf_user_icon.background_colour || bgColor;
- }
+ const markerFill = isStale ? "#d1d5db" : bgColor;
+ const markerStroke = isStale ? "#9ca3af" : iconColor;
+ const path =
+ iconPath ||
+ "M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7Zm0 11a2 2 0 1 1 0-4 2 2 0 0 1 0 4Z";
- feature.setStyle(
- new Style({
- image: new CircleStyle({
- radius: 8,
- fill: new Fill({ color: bgColor }),
- stroke: new Stroke({ color: iconColor, width: 2 }),
- }),
- text: new Text({
- text: displayName,
- offsetY: -15,
- font: "bold 11px sans-serif",
- fill: new Fill({ color: "#000" }),
- stroke: new Stroke({ color: "#fff", width: 2 }),
- }),
- })
- );
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="${path}" fill="${markerFill}" stroke="${markerStroke}" stroke-width="1.5"/></svg>`;
+ const src = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svg)));
- this.markerSource.addFeature(feature);
- }
+ const style = new Style({
+ image: new Icon({
+ src: src,
+ anchor: [0.5, 1],
+ scale: 1.6, // Reduced from 2.5
+ imgSize: [24, 24],
+ }),
+ text: new Text({
+ text: label,
+ offsetY: -45, // Adjusted from -60
+ font: "bold 12px sans-serif",
+ fill: new Fill({ color: isStale ? "#6b7280" : "#111827" }),
+ stroke: new Stroke({ color: "#ffffff", width: 3 }),
+ }),
+ });
+
+ this.styleCache[cacheKey] = style;
+ return style;
},
onMarkerClick(feature) {
this.selectedMarker = {
diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 75b32668..bcf31671 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -180,6 +180,18 @@
<div class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate">
{{ contact.remote_identity_hash }}
</div>
+ <div
+ v-if="contact.lxmf_address"
+ class="text-[9px] text-gray-400 dark:text-zinc-500 font-mono truncate"
+ >
+ LXMF: {{ contact.lxmf_address }}
+ </div>
+ <div
+ v-if="contact.lxst_address"
+ class="text-[9px] text-gray-400 dark:text-zinc-500 font-mono truncate"
+ >
+ LXST: {{ contact.lxst_address }}
+ </div>
</div>
</button>
</div>
@@ -264,11 +276,17 @@
<span class="text-sm font-bold">Contact Shared</span>
</div>
<div class="flex items-center gap-3">
- <div
- class="size-10 flex items-center justify-center rounded-full bg-blue-100 dark:bg-blue-800 text-blue-600 dark:text-blue-200 font-bold"
- >
- {{ getParsedItems(chatItem).contact.name.charAt(0).toUpperCase() }}
- </div>
+ <LxmfUserIcon
+ :custom-image="getParsedItems(chatItem).contact.custom_image"
+ :icon-name="getParsedItems(chatItem).contact.lxmf_user_icon?.icon_name"
+ :icon-foreground-colour="
+ getParsedItems(chatItem).contact.lxmf_user_icon?.foreground_colour
+ "
+ :icon-background-colour="
+ getParsedItems(chatItem).contact.lxmf_user_icon?.background_colour
+ "
+ icon-class="size-10"
+ />
<div class="flex-1 min-w-0">
<div class="text-sm font-bold text-gray-900 dark:text-white truncate">
{{ getParsedItems(chatItem).contact.name }}
@@ -278,6 +296,18 @@
>
{{ getParsedItems(chatItem).contact.hash }}
</div>
+ <div
+ v-if="getParsedItems(chatItem).contact.lxmf_address"
+ class="text-[9px] font-mono text-gray-400 dark:text-zinc-500 truncate"
+ >
+ LXMF: {{ getParsedItems(chatItem).contact.lxmf_address }}
+ </div>
+ <div
+ v-if="getParsedItems(chatItem).contact.lxst_address"
+ class="text-[9px] font-mono text-gray-400 dark:text-zinc-500 truncate"
+ >
+ LXST: {{ getParsedItems(chatItem).contact.lxst_address }}
+ </div>
</div>
</div>
<button
@@ -286,7 +316,9 @@
@click="
addContact(
getParsedItems(chatItem).contact.name,
- getParsedItems(chatItem).contact.hash
+ getParsedItems(chatItem).contact.hash,
+ getParsedItems(chatItem).contact.lxmf_address,
+ getParsedItems(chatItem).contact.lxst_address
)
"
>
@@ -1858,12 +1890,30 @@ export default {
paperMessage: null,
};
- // Parse contact: Contact: ivan <ca314c30b27eacec5f6ca6ac504e94c9>
- const contactMatch = content.match(/^Contact:\s+(.+?)\s+<([a-fA-F0-9]{32})>$/i);
+ // Parse contact: Contact: ivan <ca314c30b27eacec5f6ca6ac504e94c9> [LXMF: ...] [LXST: ...]
+ const contactMatch = content.match(
+ /^Contact:\s+(.+?)\s+<([a-fA-F0-9]{32})>(?:\s+\[LXMF:\s+([a-fA-F0-9]{32})\])?(?:\s+\[LXST:\s+([a-fA-F0-9]{32})\])?/i
+ );
if (contactMatch) {
+ const contactHash = contactMatch[2];
+ const lxmfAddress = contactMatch[3];
+ const lxstAddress = contactMatch[4];
+
+ // try to find enriched info from existing conversations/peers
+ const existing = this.conversations.find(
+ (c) =>
+ c.destination_hash === contactHash ||
+ c.destination_hash === lxmfAddress ||
+ c.destination_hash === lxstAddress
+ );
+
items.contact = {
name: contactMatch[1],
- hash: contactMatch[2],
+ hash: contactHash,
+ lxmf_address: lxmfAddress,
+ lxst_address: lxstAddress,
+ custom_image: existing?.contact_image,
+ lxmf_user_icon: existing?.lxmf_user_icon,
};
}
@@ -1881,7 +1931,7 @@ export default {
return items;
},
- async addContact(name, hash) {
+ async addContact(name, hash, lxmf_address = null, lxst_address = null) {
try {
// Check if contact already exists
const checkResponse = await window.axios.get(`/api/v1/telephone/contacts/check/${hash}`);
@@ -1893,6 +1943,8 @@ export default {
await window.axios.post("/api/v1/telephone/contacts", {
name: name,
remote_identity_hash: hash,
+ lxmf_address: lxmf_address,
+ lxst_address: lxst_address,
});
ToastUtils.success(`Added ${name} to contacts`);
} catch (e) {
@@ -2539,10 +2591,13 @@ export default {
ToastUtils.error("Failed to load contacts");
}
},
- async shareContact(contact) {
- this.newMessageText = `Contact: ${contact.name} <${contact.remote_identity_hash}>`;
+ shareContact(contact) {
+ let sharedString = `Contact: ${contact.name} <${contact.remote_identity_hash}>`;
+ if (contact.lxmf_address) sharedString += ` [LXMF: ${contact.lxmf_address}]`;
+ if (contact.lxst_address) sharedString += ` [LXST: ${contact.lxst_address}]`;
+ this.newMessageText = sharedString;
this.isShareContactModalOpen = false;
- await this.sendMessage();
+ this.sendMessage();
},
shareAsPaperMessage(chatItem) {
this.paperMessageHash = chatItem.lxmf_message.hash;
diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index 5ac0783b..1aa1de28 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -192,6 +192,7 @@ export default {
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
GlobalEmitter.off("compose-new-message", this.onComposeNewMessage);
+ GlobalEmitter.off("refresh-conversations", this.requestConversationsRefresh);
},
mounted() {
// listen for websocket messages
@@ -371,6 +372,20 @@ export default {
this.conversations = newConversations;
}
+ for (const conversation of newConversations) {
+ if (!conversation?.destination_hash) continue;
+ const existingPeer = this.peers[conversation.destination_hash] || {};
+ this.peers[conversation.destination_hash] = {
+ ...existingPeer,
+ destination_hash: conversation.destination_hash,
+ display_name: conversation.display_name ?? existingPeer.display_name,
+ custom_display_name: conversation.custom_display_name ?? existingPeer.custom_display_name,
+ contact_image: conversation.contact_image ?? existingPeer.contact_image,
+ lxmf_user_icon: conversation.lxmf_user_icon ?? existingPeer.lxmf_user_icon,
+ updated_at: conversation.updated_at ?? existingPeer.updated_at,
+ };
+ }
+
this.hasLoadedConversations = true;
this.hasMoreConversations = newConversations.length === this.pageSize;
} catch (e) {
@@ -402,7 +417,8 @@ export default {
return params;
},
updatePeerFromAnnounce: function (announce) {
- this.peers[announce.destination_hash] = announce;
+ const existing = this.peers[announce.destination_hash] || {};
+ this.peers[announce.destination_hash] = { ...existing, ...announce };
},
onPeerClick: function (peer) {
// update selected peer
diff --git a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
index b3b25f1e..9568e416 100644
--- a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
@@ -267,6 +267,7 @@
<div class="my-auto mr-2">
<LxmfUserIcon
+ :custom-image="peer.contact_image"
:icon-name="peer.lxmf_user_icon?.icon_name"
:icon-foreground-colour="peer.lxmf_user_icon?.foreground_colour"
:icon-background-colour="peer.lxmf_user_icon?.background_colour"
@@ -457,6 +458,9 @@ export default {
return matchesDisplayName || matchesCustomDisplayName || matchesDestinationHash;
});
},
+ hasUnreadConversations() {
+ return this.conversations.some((c) => c.is_unread);
+ },
},
methods: {
isBlocked(destinationHash) {
diff --git a/meshchatx/src/frontend/components/profile/ProfileIconPage.vue b/meshchatx/src/frontend/components/profile/ProfileIconPage.vue
index bed3f099..ff2a48de 100644
--- a/meshchatx/src/frontend/components/profile/ProfileIconPage.vue
+++ b/meshchatx/src/frontend/components/profile/ProfileIconPage.vue
@@ -212,6 +212,8 @@ import LxmfUserIcon from "../LxmfUserIcon.vue";
import ToastUtils from "../../js/ToastUtils";
import ColourPickerDropdown from "../ColourPickerDropdown.vue";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+import GlobalState from "../../js/GlobalState";
+import GlobalEmitter from "../../js/GlobalEmitter";
export default {
name: "ProfileIconPage",
@@ -324,6 +326,8 @@ export default {
try {
const response = await window.axios.patch("/api/v1/config", config);
this.config = response.data.config;
+ GlobalState.config = response.data.config;
+ GlobalEmitter.emit("config-updated", response.data.config);
this.saveOriginalValues();
if (!silent) {
diff --git a/meshchatx/src/frontend/components/tools/BotsPage.vue b/meshchatx/src/frontend/components/tools/BotsPage.vue
new file mode 100644
index 00000000..c1c30b89
--- /dev/null
+++ b/meshchatx/src/frontend/components/tools/BotsPage.vue
@@ -0,0 +1,326 @@
+<template>
+ <div
+ class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
+ >
+ <div class="flex-1 overflow-y-auto w-full px-4 md:px-8 py-6">
+ <div class="space-y-4 w-full max-w-4xl mx-auto">
+ <div class="glass-card space-y-5">
+ <div class="space-y-2">
+ <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
+ {{ $t("bots.bot_framework") }}
+ </div>
+ <div class="text-2xl font-semibold text-gray-900 dark:text-white">{{ $t("bots.title") }}</div>
+ <div class="text-sm text-gray-600 dark:text-gray-300">
+ {{ $t("bots.description") }}
+ </div>
+ </div>
+
+ <div class="space-y-6">
+ <!-- Create New Bot -->
+ <div class="space-y-4">
+ <h3 class="text-lg font-semibold text-gray-900 dark:text-white">
+ {{ $t("bots.create_new_bot") }}
+ </h3>
+ <div class="grid sm:grid-cols-2 gap-4">
+ <div
+ v-for="template in templates"
+ :key="template.id"
+ class="glass-card !p-4 hover:border-blue-400 transition cursor-pointer flex flex-col justify-between"
+ @click="selectTemplate(template)"
+ >
+ <div>
+ <div class="font-bold text-gray-900 dark:text-white">{{ template.name }}</div>
+ <div class="text-sm text-gray-600 dark:text-gray-400 mt-1">
+ {{ template.description }}
+ </div>
+ </div>
+ <button
+ class="primary-chip w-full mt-4 py-2"
+ @click.stop="selectTemplate(template)"
+ >
+ {{ $t("bots.select") }}
+ </button>
+ </div>
+
+ <!-- More bots coming soon -->
+ <div
+ class="glass-card !p-4 border-dashed border-2 border-gray-200 dark:border-zinc-800 flex flex-col items-center justify-center opacity-60"
+ >
+ <div class="p-2 bg-gray-100 dark:bg-zinc-800 rounded-lg mb-2">
+ <MaterialDesignIcon
+ icon-name="plus"
+ class="size-6 text-gray-400 dark:text-gray-500"
+ />
+ </div>
+ <div class="text-sm font-medium text-gray-500 dark:text-gray-400 text-center">
+ {{ $t("bots.more_bots_coming") }}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Saved Bots -->
+ <div class="space-y-4">
+ <h3 class="text-lg font-semibold text-gray-900 dark:text-white">
+ {{ $t("bots.saved_bots") }}
+ </h3>
+ <div v-if="bots.length === 0" class="text-sm text-gray-500 italic">
+ {{ $t("bots.no_bots_running") }}
+ </div>
+ <div v-else class="space-y-3">
+ <div
+ v-for="bot in bots"
+ :key="bot.id"
+ class="glass-card !p-4 flex items-center justify-between"
+ >
+ <div class="flex items-center gap-3">
+ <div class="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
+ <MaterialDesignIcon
+ icon-name="robot"
+ class="size-6 text-blue-600 dark:text-blue-400"
+ />
+ </div>
+ <div>
+ <div class="font-bold text-gray-900 dark:text-white">{{ bot.name }}</div>
+ <div class="text-xs font-mono text-gray-500">
+ {{ runningMap[bot.id]?.address || "Not running" }}
+ </div>
+ <div class="text-[10px] text-gray-400">
+ {{ bot.template_id || bot.template }}
+ </div>
+ <div v-if="bot.storage_dir" class="text-[10px] text-gray-400">
+ {{ bot.storage_dir }}
+ </div>
+ </div>
+ </div>
+ <div class="flex items-center gap-2">
+ <template v-if="runningMap[bot.id]">
+ <button
+ class="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
+ :title="$t('bots.stop_bot')"
+ @click="stopBot(bot.id)"
+ >
+ <MaterialDesignIcon icon-name="stop" class="size-5" />
+ </button>
+ <button
+ class="p-2 text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
+ :title="$t('bots.restart_bot')"
+ @click="restartExisting(bot)"
+ >
+ <MaterialDesignIcon icon-name="refresh" class="size-5" />
+ </button>
+ </template>
+ <template v-else>
+ <button class="primary-chip px-3 py-1 text-xs" @click="startExisting(bot)">
+ {{ $t("bots.start_bot") }}
+ </button>
+ </template>
+ <button
+ class="p-2 text-gray-500 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg transition-colors"
+ :title="$t('bots.export_identity')"
+ @click="exportIdentity(bot.id)"
+ >
+ <MaterialDesignIcon icon-name="export" class="size-5" />
+ </button>
+ <button
+ class="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
+ :title="$t('bots.delete_bot')"
+ @click="deleteBot(bot.id)"
+ >
+ <MaterialDesignIcon icon-name="delete" class="size-5" />
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Start Bot Modal -->
+ <div
+ v-if="selectedTemplate"
+ class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
+ @click.self="selectedTemplate = null"
+ >
+ <div class="glass-card max-w-md w-full space-y-4">
+ <div class="flex justify-between items-center">
+ <h3 class="text-xl font-bold text-gray-900 dark:text-white">
+ {{ $t("bots.start_bot") }}: {{ selectedTemplate.name }}
+ </h3>
+ <button
+ class="p-1 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg"
+ @click="selectedTemplate = null"
+ >
+ <MaterialDesignIcon icon-name="close" class="size-5" />
+ </button>
+ </div>
+
+ <div class="space-y-4">
+ <div>
+ <label class="glass-label">{{ $t("bots.bot_name") }}</label>
+ <input
+ v-model="newBotName"
+ type="text"
+ :placeholder="selectedTemplate.name"
+ class="input-field"
+ />
+ </div>
+
+ <div class="text-sm text-gray-600 dark:text-gray-400">
+ {{ selectedTemplate.description }}
+ </div>
+
+ <div class="flex gap-3 justify-end pt-2">
+ <button class="secondary-chip px-6 py-2" @click="selectedTemplate = null">
+ {{ $t("bots.cancel") }}
+ </button>
+ <button class="primary-chip px-6 py-2" :disabled="isStarting" @click="startBot">
+ <span
+ v-if="isStarting"
+ class="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"
+ ></span>
+ {{ $t("bots.start_bot") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import ToastUtils from "../../js/ToastUtils";
+import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+
+export default {
+ name: "BotsPage",
+ components: {
+ MaterialDesignIcon,
+ },
+ data() {
+ return {
+ bots: [],
+ templates: [],
+ runningBots: [],
+ selectedTemplate: null,
+ newBotName: "",
+ isStarting: false,
+ loading: true,
+ refreshInterval: null,
+ };
+ },
+ computed: {
+ runningMap() {
+ const map = {};
+ this.runningBots.forEach((b) => {
+ map[b.id] = b;
+ });
+ return map;
+ },
+ },
+ mounted() {
+ this.getStatus();
+ this.refreshInterval = setInterval(this.getStatus, 5000);
+ },
+ beforeUnmount() {
+ if (this.refreshInterval) {
+ clearInterval(this.refreshInterval);
+ }
+ },
+ methods: {
+ async getStatus() {
+ try {
+ const response = await window.axios.get("/api/v1/bots/status");
+ this.bots = response.data.status.bots || [];
+ this.runningBots = response.data.status.running_bots;
+ this.templates = response.data.templates;
+ this.loading = false;
+ } catch (e) {
+ console.error(e);
+ }
+ },
+ selectTemplate(template) {
+ this.selectedTemplate = template;
+ this.newBotName = template.name;
+ },
+ async startBot() {
+ if (this.isStarting) return;
+ this.isStarting = true;
+ try {
+ await window.axios.post("/api/v1/bots/start", {
+ template_id: this.selectedTemplate.id,
+ name: this.newBotName,
+ });
+ ToastUtils.success(this.$t("bots.bot_started"));
+ this.selectedTemplate = null;
+ this.getStatus();
+ } catch (e) {
+ console.error(e);
+ ToastUtils.error(e.response?.data?.message || this.$t("bots.failed_to_start"));
+ } finally {
+ this.isStarting = false;
+ }
+ },
+ async stopBot(botId) {
+ try {
+ await window.axios.post("/api/v1/bots/stop", { bot_id: botId });
+ ToastUtils.success(this.$t("bots.bot_stopped"));
+ this.getStatus();
+ } catch (e) {
+ console.error(e);
+ ToastUtils.error(this.$t("bots.failed_to_stop"));
+ }
+ },
+ async startExisting(bot) {
+ try {
+ await window.axios.post("/api/v1/bots/start", {
+ bot_id: bot.id,
+ template_id: bot.template_id,
+ name: bot.name,
+ });
+ ToastUtils.success(this.$t("bots.bot_started"));
+ this.getStatus();
+ } catch (e) {
+ console.error(e);
+ ToastUtils.error(e.response?.data?.message || this.$t("bots.failed_to_start"));
+ }
+ },
+ async restartExisting(bot) {
+ try {
+ await window.axios.post("/api/v1/bots/restart", { bot_id: bot.id });
+ ToastUtils.success(this.$t("bots.bot_started"));
+ this.getStatus();
+ } catch (e) {
+ console.error(e);
+ ToastUtils.error(e.response?.data?.message || this.$t("bots.failed_to_start"));
+ }
+ },
+ async deleteBot(botId) {
+ if (!confirm(this.$t("common.delete_confirm"))) return;
+ try {
+ await window.axios.post("/api/v1/bots/delete", { bot_id: botId });
+ ToastUtils.success(this.$t("bots.bot_deleted"));
+ this.getStatus();
+ } catch (e) {
+ console.error(e);
+ ToastUtils.error(this.$t("bots.failed_to_delete"));
+ }
+ },
+ exportIdentity(botId) {
+ window.open(`/api/v1/bots/export?bot_id=${botId}`, "_blank");
+ },
+ copyToClipboard(text) {
+ navigator.clipboard.writeText(text);
+ ToastUtils.success(this.$t("translator.copied_to_clipboard"));
+ },
+ },
+};
+</script>
+
+<style scoped>
+.glass-label {
+ @apply block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1;
+}
+</style>
diff --git a/meshchatx/src/frontend/components/tools/ToolsPage.vue b/meshchatx/src/frontend/components/tools/ToolsPage.vue
index e68e4d57..b54b6d78 100644
--- a/meshchatx/src/frontend/components/tools/ToolsPage.vue
+++ b/meshchatx/src/frontend/components/tools/ToolsPage.vue
@@ -3,7 +3,7 @@
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
<div class="flex-1 overflow-y-auto w-full">
- <div class="space-y-4 p-4 md:p-6 max-w-5xl mx-auto w-full">
+ <div class="space-y-4 p-4 md:p-6 lg:p-8 w-full">
<div class="glass-card space-y-3">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("tools.utilities") }}
@@ -16,184 +16,61 @@
</div>
</div>
- <div class="grid gap-4 md:grid-cols-2">
- <RouterLink :to="{ name: 'ping' }" class="tool-card glass-card">
- <div class="tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200">
- <MaterialDesignIcon icon-name="radar" class="w-6 h-6" />
- </div>
- <div class="flex-1">
- <div class="tool-card__title">{{ $t("tools.ping.title") }}</div>
- <div class="tool-card__description">
- {{ $t("tools.ping.description") }}
- </div>
- </div>
- <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
- </RouterLink>
-
- <RouterLink :to="{ name: 'rnprobe' }" class="tool-card glass-card">
- <div
- class="tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200"
- >
- <MaterialDesignIcon icon-name="radar" class="w-6 h-6" />
- </div>
- <div class="flex-1">
- <div class="tool-card__title">{{ $t("tools.rnprobe.title") }}</div>
- <div class="tool-card__description">
- {{ $t("tools.rnprobe.description") }}
- </div>
- </div>
- <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
- </RouterLink>
-
- <RouterLink :to="{ name: 'rncp' }" class="tool-card glass-card">
- <div
- class="tool-card__icon bg-green-50 text-green-500 dark:bg-green-900/30 dark:text-green-200"
- >
- <MaterialDesignIcon icon-name="swap-horizontal" class="w-6 h-6" />
- </div>
- <div class="flex-1">
- <div class="tool-card__title">{{ $t("tools.rncp.title") }}</div>
- <div class="tool-card__description">
- {{ $t("tools.rncp.description") }}
- </div>
- </div>
- <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
- </RouterLink>
-
- <RouterLink :to="{ name: 'rnstatus' }" class="tool-card glass-card">
- <div
- class="tool-card__icon bg-orange-50 text-orange-500 dark:bg-orange-900/30 dark:text-orange-200"
- >
- <MaterialDesignIcon icon-name="chart-line" class="w-6 h-6" />
- </div>
- <div class="flex-1">
- <div class="tool-card__title">{{ $t("tools.rnstatus.title") }}</div>
- <div class="tool-card__description">
- {{ $t("tools.rnstatus.description") }}
- </div>
- </div>
- <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
- </RouterLink>
-
- <RouterLink :to="{ name: 'rnpath' }" class="tool-card glass-card">
- <div
- class="tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200"
- >
- <MaterialDesignIcon icon-name="route" class="w-6 h-6" />
- </div>
- <div class="flex-1">
- <div class="tool-card__title">{{ $t("tools.rnpath.title") }}</div>
- <div class="tool-card__description">
- {{ $t("tools.rnpath.description") }}
- </div>
- </div>
- <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
- </RouterLink>
-
- <RouterLink :to="{ name: 'translator' }" class="tool-card glass-card">
- <div
- class="tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200"
- >
- <MaterialDesignIcon icon-name="translate" class="w-6 h-6" />
- </div>
- <div class="flex-1">
- <div class="tool-card__title">{{ $t("tools.translator.title") }}</div>
- <div class="tool-card__description">
- {{ $t("tools.translator.description") }}
- </div>
- </div>
- <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
- </RouterLink>
-
- <RouterLink :to="{ name: 'forwarder' }" class="tool-card glass-card">
- <div class="tool-card__icon bg-rose-50 text-rose-500 dark:bg-rose-900/30 dark:text-rose-200">
- <MaterialDesignIcon icon-name="email-send-outline" class="w-6 h-6" />
- </div>
- <div class="flex-1">
- <div class="tool-card__title">{{ $t("tools.forwarder.title") }}</div>
- <div class="tool-card__description">
- {{ $t("tools.forwarder.description") }}
- </div>
- </div>
- <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
- </RouterLink>
-
- <RouterLink :to="{ name: 'documentation' }" class="tool-card glass-card">
- <div class="tool-card__icon bg-cyan-50 text-cyan-500 dark:bg-cyan-900/30 dark:text-cyan-200">
- <MaterialDesignIcon icon-name="book-open-variant" class="w-6 h-6" />
- </div>
- <div class="flex-1">
- <div class="tool-card__title">{{ $t("docs.title") }}</div>
- <div class="tool-card__description">
- {{ $t("docs.subtitle") }}
- </div>
- </div>
- <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
- </RouterLink>
-
- <RouterLink :to="{ name: 'micron-editor' }" class="tool-card glass-card">
- <div class="tool-card__icon bg-teal-50 text-teal-500 dark:bg-teal-900/30 dark:text-teal-200">
- <MaterialDesignIcon icon-name="code-tags" class="w-6 h-6" />
- </div>
- <div class="flex-1">
- <div class="tool-card__title">{{ $t("tools.micron_editor.title") }}</div>
- <div class="tool-card__description">
- {{ $t("tools.micron_editor.description") }}
- </div>
- </div>
- <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
- </RouterLink>
-
- <RouterLink :to="{ name: 'paper-message' }" class="tool-card glass-card">
- <div class="tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200">
- <MaterialDesignIcon icon-name="qrcode" class="w-6 h-6" />
- </div>
- <div class="flex-1">
- <div class="tool-card__title">{{ $t("tools.paper_message.title") }}</div>
- <div class="tool-card__description">
- {{ $t("tools.paper_message.description") }}
- </div>
- </div>
- <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
- </RouterLink>
+ <div class="glass-card">
+ <div class="relative">
+ <MaterialDesignIcon
+ icon-name="magnify"
+ class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400"
+ />
+ <input
+ v-model="searchQuery"
+ type="text"
+ :placeholder="$t('common.search')"
+ class="w-full pl-10 pr-4 py-3 bg-white/50 dark:bg-zinc-900/50 border border-gray-200 dark:border-zinc-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500"
+ />
+ </div>
+ </div>
- <RouterLink :to="{ name: 'rnode-flasher' }" class="tool-card glass-card">
- <div
- class="tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200"
- >
- <img :src="rnodeLogoPath" class="w-8 h-8 rounded-full" alt="RNode" />
+ <div class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
+ <RouterLink
+ v-for="tool in filteredTools"
+ :key="tool.name"
+ :to="tool.route"
+ :class="['tool-card', 'glass-card', tool.customClass].filter(Boolean)"
+ >
+ <div :class="tool.iconBg">
+ <MaterialDesignIcon v-if="tool.icon" :icon-name="tool.icon" class="w-6 h-6" />
+ <img
+ v-else-if="tool.image"
+ :src="tool.image"
+ :class="tool.imageClass"
+ :alt="tool.imageAlt"
+ />
</div>
<div class="flex-1">
- <div class="tool-card__title">{{ $t("tools.rnode_flasher.title") }}</div>
+ <div class="tool-card__title">{{ tool.title }}</div>
<div class="tool-card__description">
- {{ $t("tools.rnode_flasher.description") }}
+ {{ tool.description }}
</div>
</div>
- <div class="flex items-center gap-2">
+ <div v-if="tool.extraAction" class="flex items-center gap-2">
<a
- href="/rnode-flasher/index.html"
- target="_blank"
+ :href="tool.extraAction.href"
+ :target="tool.extraAction.target"
class="p-2 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors text-gray-400 hover:text-blue-500"
@click.stop
>
- <MaterialDesignIcon icon-name="open-in-new" class="size-5" />
+ <MaterialDesignIcon :icon-name="tool.extraAction.icon" class="size-5" />
</a>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</div>
+ <MaterialDesignIcon v-else icon-name="chevron-right" class="tool-card__chevron" />
</RouterLink>
+ </div>
- <RouterLink :to="{ name: 'debug-logs' }" class="tool-card glass-card border-dashed border-2">
- <div class="tool-card__icon bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
- <MaterialDesignIcon icon-name="console" class="w-6 h-6" />
- </div>
- <div class="flex-1">
- <div class="tool-card__title">Debug Logs</div>
- <div class="tool-card__description">
- View and export internal system logs for troubleshooting.
- </div>
- </div>
- <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
- </RouterLink>
+ <div v-if="filteredTools.length === 0" class="glass-card text-center py-12">
+ <MaterialDesignIcon icon-name="magnify" class="w-12 h-12 mx-auto mb-4 text-gray-400" />
+ <div class="text-gray-600 dark:text-gray-400">{{ $t("common.no_results") }}</div>
</div>
</div>
</div>
@@ -210,8 +87,148 @@ export default {
data() {
return {
rnodeLogoPath: "/rnode-flasher/reticulum_logo_512.png",
+ searchQuery: "",
+ tools: [
+ {
+ name: "ping",
+ route: { name: "ping" },
+ icon: "radar",
+ iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
+ titleKey: "tools.ping.title",
+ descriptionKey: "tools.ping.description",
+ },
+ {
+ name: "rnprobe",
+ route: { name: "rnprobe" },
+ icon: "radar",
+ iconBg: "tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200",
+ titleKey: "tools.rnprobe.title",
+ descriptionKey: "tools.rnprobe.description",
+ },
+ {
+ name: "rncp",
+ route: { name: "rncp" },
+ icon: "swap-horizontal",
+ iconBg: "tool-card__icon bg-green-50 text-green-500 dark:bg-green-900/30 dark:text-green-200",
+ titleKey: "tools.rncp.title",
+ descriptionKey: "tools.rncp.description",
+ },
+ {
+ name: "rnstatus",
+ route: { name: "rnstatus" },
+ icon: "chart-line",
+ iconBg: "tool-card__icon bg-orange-50 text-orange-500 dark:bg-orange-900/30 dark:text-orange-200",
+ titleKey: "tools.rnstatus.title",
+ descriptionKey: "tools.rnstatus.description",
+ },
+ {
+ name: "rnpath",
+ route: { name: "rnpath" },
+ icon: "route",
+ iconBg: "tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200",
+ titleKey: "tools.rnpath.title",
+ descriptionKey: "tools.rnpath.description",
+ },
+ {
+ name: "translator",
+ route: { name: "translator" },
+ icon: "translate",
+ iconBg: "tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200",
+ titleKey: "tools.translator.title",
+ descriptionKey: "tools.translator.description",
+ },
+ {
+ name: "bots",
+ route: { name: "bots" },
+ icon: "robot",
+ iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
+ titleKey: "tools.bots.title",
+ descriptionKey: "tools.bots.description",
+ },
+ {
+ name: "forwarder",
+ route: { name: "forwarder" },
+ icon: "email-send-outline",
+ iconBg: "tool-card__icon bg-rose-50 text-rose-500 dark:bg-rose-900/30 dark:text-rose-200",
+ titleKey: "tools.forwarder.title",
+ descriptionKey: "tools.forwarder.description",
+ },
+ {
+ name: "documentation",
+ route: { name: "documentation" },
+ icon: "book-open-variant",
+ iconBg: "tool-card__icon bg-cyan-50 text-cyan-500 dark:bg-cyan-900/30 dark:text-cyan-200",
+ titleKey: "docs.title",
+ descriptionKey: "docs.subtitle",
+ },
+ {
+ name: "micron-editor",
+ route: { name: "micron-editor" },
+ icon: "code-tags",
+ iconBg: "tool-card__icon bg-teal-50 text-teal-500 dark:bg-teal-900/30 dark:text-teal-200",
+ titleKey: "tools.micron_editor.title",
+ descriptionKey: "tools.micron_editor.description",
+ },
+ {
+ name: "paper-message",
+ route: { name: "paper-message" },
+ icon: "qrcode",
+ iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
+ titleKey: "tools.paper_message.title",
+ descriptionKey: "tools.paper_message.description",
+ },
+ {
+ name: "rnode-flasher",
+ route: { name: "rnode-flasher" },
+ icon: null,
+ image: "/rnode-flasher/reticulum_logo_512.png",
+ imageClass: "w-8 h-8 rounded-full",
+ imageAlt: "RNode",
+ iconBg: "tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200",
+ titleKey: "tools.rnode_flasher.title",
+ descriptionKey: "tools.rnode_flasher.description",
+ extraAction: {
+ href: "/rnode-flasher/index.html",
+ target: "_blank",
+ icon: "open-in-new",
+ },
+ },
+ {
+ name: "debug-logs",
+ route: { name: "debug-logs" },
+ icon: "console",
+ iconBg: "tool-card__icon bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400",
+ titleKey: null,
+ title: "Debug Logs",
+ descriptionKey: null,
+ description: "View and export internal system logs for troubleshooting.",
+ customClass: "border-dashed border-2",
+ },
+ ],
};
},
+ computed: {
+ filteredTools() {
+ const toolsWithTranslations = this.tools.map((tool) => ({
+ ...tool,
+ title: tool.title || (tool.titleKey ? this.$t(tool.titleKey) : ""),
+ description: tool.description || (tool.descriptionKey ? this.$t(tool.descriptionKey) : ""),
+ }));
+
+ if (!this.searchQuery.trim()) {
+ return toolsWithTranslations;
+ }
+
+ const query = this.searchQuery.toLowerCase().trim();
+ return toolsWithTranslations.filter((tool) => {
+ return (
+ tool.title.toLowerCase().includes(query) ||
+ tool.description.toLowerCase().includes(query) ||
+ tool.name.toLowerCase().includes(query)
+ );
+ });
+ },
+ },
};
</script>
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index 10eed8a6..adee9228 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -1,4 +1,31 @@
{
+ "bots": {
+ "title": "LXMFy-Bots",
+ "description": "Verwalten Sie automatisierte Bots für Echo, Notizen und Erinnerungen mit LXMFy.",
+ "bot_framework": "Bot-Framework",
+ "lxmfy_not_detected": "LXMFy nicht erkannt",
+ "lxmfy_not_detected_desc": "Um Bots zu verwenden, müssen Sie das LXMFy-Paket installieren:",
+ "install_via_pip": "Über pip installieren",
+ "create_new_bot": "Neuen Bot erstellen",
+ "running_bots": "Laufende Bots",
+ "no_bots_running": "Derzeit laufen keine Bots.",
+ "select": "Auswählen",
+ "start_bot": "Bot starten",
+ "stop_bot": "Bot stoppen",
+ "restart_bot": "Bot neu starten",
+ "saved_bots": "Gespeicherte Bots",
+ "bot_name": "Bot-Name",
+ "cancel": "Abbrechen",
+ "bot_started": "Bot erfolgreich gestartet",
+ "bot_stopped": "Bot gestoppt",
+ "failed_to_start": "Bot konnte nicht gestartet werden",
+ "failed_to_stop": "Bot konnte nicht gestoppt werden",
+ "delete_bot": "Bot löschen",
+ "export_identity": "Identität exportieren",
+ "bot_deleted": "Bot erfolgreich gelöscht",
+ "failed_to_delete": "Bot konnte nicht gelöscht werden",
+ "more_bots_coming": "Weitere Bots folgen in Kürze!"
+ },
"app": {
"name": "Reticulum MeshChatX",
"sync_messages": "Nachrichten synchronisieren",
@@ -183,7 +210,9 @@
"shutdown": "Ausschalten",
"acknowledge_reset": "Bestätigen & Zurücksetzen",
"confirm": "Bestätigen",
- "delete_confirm": "Sind Sie sicher, dass Sie dies löschen möchten? Dies kann nicht rückgängig gemacht werden."
+ "delete_confirm": "Sind Sie sicher, dass Sie dies löschen möchten? Dies kann nicht rückgängig gemacht werden.",
+ "search": "Werkzeuge suchen...",
+ "no_results": "Keine Werkzeuge gefunden"
},
"identities": {
"title": "Identitäten",
@@ -665,6 +694,10 @@
"paper_message": {
"title": "Papiernachricht",
"description": "Erstellen und lesen Sie LXMF-signierte Papiernachrichten über QR-Codes."
+ },
+ "bots": {
+ "title": "LXMFy-Bots",
+ "description": "Verwalten Sie automatisierte Bots für Echo, Notizen und Erinnerungen mit LXMFy."
}
},
"ping": {
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 0cf09344..e663b41d 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -1,4 +1,31 @@
{
+ "bots": {
+ "title": "LXMFy Bots",
+ "description": "Manage automated bots for echo, notes, and reminders using LXMFy.",
+ "bot_framework": "Bot Framework",
+ "lxmfy_not_detected": "LXMFy not detected",
+ "lxmfy_not_detected_desc": "To use bots, you must install the LXMFy package:",
+ "install_via_pip": "Install via pip",
+ "create_new_bot": "Create New Bot",
+ "running_bots": "Running Bots",
+ "no_bots_running": "No bots are currently running.",
+ "select": "Select",
+ "start_bot": "Start Bot",
+ "stop_bot": "Stop Bot",
+ "restart_bot": "Restart Bot",
+ "saved_bots": "Saved Bots",
+ "bot_name": "Bot Name",
+ "cancel": "Cancel",
+ "bot_started": "Bot started successfully",
+ "bot_stopped": "Bot stopped",
+ "failed_to_start": "Failed to start bot",
+ "failed_to_stop": "Failed to stop bot",
+ "delete_bot": "Delete Bot",
+ "export_identity": "Export Identity",
+ "bot_deleted": "Bot deleted successfully",
+ "failed_to_delete": "Failed to delete bot",
+ "more_bots_coming": "More bots coming soon!"
+ },
"app": {
"name": "Reticulum MeshChatX",
"sync_messages": "Sync Messages",
@@ -183,7 +210,9 @@
"shutdown": "Shutdown",
"acknowledge_reset": "Acknowledge & Reset",
"confirm": "Confirm",
- "delete_confirm": "Are you sure you want to delete this? This cannot be undone."
+ "delete_confirm": "Are you sure you want to delete this? This cannot be undone.",
+ "search": "Search tools...",
+ "no_results": "No tools found"
},
"identities": {
"title": "Identities",
@@ -665,6 +694,10 @@
"paper_message": {
"title": "Paper Message",
"description": "Generate and read LXMF signed paper messages via QR codes."
+ },
+ "bots": {
+ "title": "LXMFy Bots",
+ "description": "Manage automated bots for echo, notes, and reminders using LXMFy."
}
},
"ping": {
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index e1db5aec..e63e46ba 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -1,4 +1,31 @@
{
+ "bots": {
+ "title": "Боты LXMFy",
+ "description": "Управление автоматическими ботами для эха, заметок и напоминаний с помощью LXMFy.",
+ "bot_framework": "Фреймворк ботов",
+ "lxmfy_not_detected": "LXMFy не обнаружен",
+ "lxmfy_not_detected_desc": "Для использования ботов необходимо установить пакет LXMFy:",
+ "install_via_pip": "Установить через pip",
+ "create_new_bot": "Создать нового бота",
+ "running_bots": "Запущенные боты",
+ "no_bots_running": "В данный момент нет запущенных ботов.",
+ "select": "Выбрать",
+ "start_bot": "Запустить бота",
+ "stop_bot": "Остановить бота",
+ "restart_bot": "Перезапустить бота",
+ "saved_bots": "Сохраненные боты",
+ "bot_name": "Имя бота",
+ "cancel": "Отмена",
+ "bot_started": "Бот успешно запущен",
+ "bot_stopped": "Бот остановлен",
+ "failed_to_start": "Не удалось запустить бота",
+ "failed_to_stop": "Не удалось остановить бота",
+ "delete_bot": "Удалить бота",
+ "export_identity": "Экспорт личности",
+ "bot_deleted": "Бот успешно удален",
+ "failed_to_delete": "Не удалось удалить бота",
+ "more_bots_coming": "Скоро появятся новые боты!"
+ },
"app": {
"name": "Reticulum MeshChatX",
"sync_messages": "Синхронизировать сообщения",
@@ -183,7 +210,9 @@
"shutdown": "Выключить",
"acknowledge_reset": "Подтвердить и сбросить",
"confirm": "Подтвердить",
- "delete_confirm": "Вы уверены, что хотите удалить это? Это действие нельзя отменить."
+ "delete_confirm": "Вы уверены, что хотите удалить это? Это действие нельзя отменить.",
+ "search": "Поиск инструментов...",
+ "no_results": "Инструменты не найдены"
},
"identities": {
"title": "Личности",
@@ -665,6 +694,10 @@
"paper_message": {
"title": "Бумажное сообщение",
"description": "Создание и чтение подписанных бумажных сообщений LXMF через QR-коды."
+ },
+ "bots": {
+ "title": "LXMFy Боты",
+ "description": "Управление автоматизированными ботами для эха, заметок и напоминаний с помощью LXMFy."
}
},
"ping": {
diff --git a/meshchatx/src/frontend/main.js b/meshchatx/src/frontend/main.js
index 37f866f9..b4dcf8ce 100644
--- a/meshchatx/src/frontend/main.js
+++ b/meshchatx/src/frontend/main.js
@@ -189,6 +189,11 @@ const router = createRouter({
path: "/translator",
component: defineAsyncComponent(() => import("./components/translator/TranslatorPage.vue")),
},
+ {
+ name: "bots",
+ path: "/bots",
+ component: defineAsyncComponent(() => import("./components/tools/BotsPage.vue")),
+ },
{
name: "forwarder",
path: "/forwarder",
diff --git a/scripts/generate_locale_template.py b/scripts/generate_locale_template.py
index 99e628fe..343e8751 100644
--- a/scripts/generate_locale_template.py
+++ b/scripts/generate_locale_template.py
@@ -20,7 +20,7 @@ def main():
sys.exit(1)
try:
- with open(en_path, "r", encoding="utf-8") as f:
+ with open(en_path, encoding="utf-8") as f:
en_data = json.load(f)
template = clear_values(en_data)
@@ -29,7 +29,7 @@ def main():
json.dump(template, f, indent=4, ensure_ascii=False)
print(
- f"Successfully generated {out_path} with all keys from {en_path} (empty values)."
+ f"Successfully generated {out_path} with all keys from {en_path} (empty values).",
)
except Exception as e:
print(f"Error generating locale template: {e}")
diff --git a/tests/backend/benchmark_db_lite.py b/tests/backend/benchmark_db_lite.py
index 6a29a569..2efe682e 100644
--- a/tests/backend/benchmark_db_lite.py
+++ b/tests/backend/benchmark_db_lite.py
@@ -1,9 +1,10 @@
import os
+import random
+import secrets
import shutil
import tempfile
import time
-import random
-import secrets
+
from meshchatx.src.backend.database import Database
@@ -68,7 +69,7 @@ def test_db_performance():
convs = db.messages.get_conversations()
end_time = time.time()
print(
- f"get_conversations() returned {len(convs)} conversations in {end_time - start_time:.4f} seconds"
+ f"get_conversations() returned {len(convs)} conversations in {end_time - start_time:.4f} seconds",
)
# Test get_conversation_messages for a random peer
@@ -78,7 +79,7 @@ def test_db_performance():
msgs = db.messages.get_conversation_messages(target_peer, limit=50)
end_time = time.time()
print(
- f"get_conversation_messages() returned {len(msgs)} messages in {end_time - start_time:.4f} seconds"
+ f"get_conversation_messages() returned {len(msgs)} messages in {end_time - start_time:.4f} seconds",
)
# Test unread states for all peers
@@ -87,7 +88,7 @@ def test_db_performance():
_ = db.messages.get_conversations_unread_states(peer_hashes)
end_time = time.time()
print(
- f"get_conversations_unread_states() for {len(peer_hashes)} peers took {end_time - start_time:.4f} seconds"
+ f"get_conversations_unread_states() for {len(peer_hashes)} peers took {end_time - start_time:.4f} seconds",
)
# Test announces performance
diff --git a/tests/backend/benchmarking_utils.py b/tests/backend/benchmarking_utils.py
index a39088f6..5296a79b 100644
--- a/tests/backend/benchmarking_utils.py
+++ b/tests/backend/benchmarking_utils.py
@@ -1,9 +1,10 @@
-import os
-import psutil
import gc
+import os
import time
from functools import wraps
+import psutil
+
def get_memory_usage_mb():
"""Returns the current process memory usage in MB."""
@@ -81,5 +82,5 @@ class MemoryTracker:
self.duration_ms = (self.end_time - self.start_time) * 1000
self.mem_delta = self.end_mem - self.start_mem
print(
- f"TRACKER [{self.name}]: {self.duration_ms:.2f}ms, {self.mem_delta:.2f}MB"
+ f"TRACKER [{self.name}]: {self.duration_ms:.2f}ms, {self.mem_delta:.2f}MB",
)
diff --git a/tests/backend/conftest.py b/tests/backend/conftest.py
index bc52c9ae..10c3c1f6 100644
--- a/tests/backend/conftest.py
+++ b/tests/backend/conftest.py
@@ -1,6 +1,7 @@
-import pytest
-from unittest.mock import patch
import asyncio
+from unittest.mock import patch
+
+import pytest
@pytest.fixture(autouse=True)
diff --git a/tests/backend/map_benchmarks.py b/tests/backend/map_benchmarks.py
index 99470971..2d75c19b 100644
--- a/tests/backend/map_benchmarks.py
+++ b/tests/backend/map_benchmarks.py
@@ -1,13 +1,15 @@
+import gc
+import json
import os
+import random
+import secrets
import shutil
import tempfile
import time
-import random
-import secrets
-import psutil
-import gc
-import json
from unittest.mock import MagicMock
+
+import psutil
+
from meshchatx.src.backend.database import Database
@@ -72,7 +74,9 @@ class MapBenchmarker:
)
self.record_benchmark(
- f"Telemetry Insertion ({count} entries)", run_telemetry, count
+ f"Telemetry Insertion ({count} entries)",
+ run_telemetry,
+ count,
)
def benchmark_telemetry_retrieval(self, count=100):
@@ -90,7 +94,9 @@ class MapBenchmarker:
self.db.telemetry.get_telemetry_history(dest_hash, limit=100)
self.record_benchmark(
- f"Telemetry History Retrieval ({count} calls)", run_retrieval, count
+ f"Telemetry History Retrieval ({count} calls)",
+ run_retrieval,
+ count,
)
def benchmark_drawing_storage(self, count=500):
@@ -112,7 +118,7 @@ class MapBenchmarker:
}
for i in range(100)
],
- }
+ },
)
def run_drawings():
@@ -125,7 +131,9 @@ class MapBenchmarker:
)
self.record_benchmark(
- f"Map Drawing Insertion ({count} layers)", run_drawings, count
+ f"Map Drawing Insertion ({count} layers)",
+ run_drawings,
+ count,
)
def benchmark_drawing_listing(self, count=100):
@@ -154,7 +162,9 @@ class MapBenchmarker:
mm.list_mbtiles()
self.record_benchmark(
- f"MBTiles Listing ({count} calls, 5 files)", run_list, count
+ f"MBTiles Listing ({count} calls, 5 files)",
+ run_list,
+ count,
)
@@ -173,7 +183,7 @@ def main():
print("-" * 80)
for r in bench.results:
print(
- f"{r['name']:40} | {r['duration_ms']:8.2f} ms | {r['memory_growth_mb']:8.2f} MB"
+ f"{r['name']:40} | {r['duration_ms']:8.2f} ms | {r['memory_growth_mb']:8.2f} MB",
)
print("=" * 80)
diff --git a/tests/backend/memory_benchmarks.py b/tests/backend/memory_benchmarks.py
index 1ab3e35e..a6a4ee35 100644
--- a/tests/backend/memory_benchmarks.py
+++ b/tests/backend/memory_benchmarks.py
@@ -1,11 +1,13 @@
+import gc
import os
+import random
+import secrets
import shutil
import tempfile
import time
-import random
-import secrets
+
import psutil
-import gc
+
from meshchatx.src.backend.database import Database
from meshchatx.src.backend.recovery import CrashRecovery
@@ -112,7 +114,9 @@ class PerformanceBenchmarker:
recovery.run_diagnosis(file=open(os.devnull, "w"))
self.record_benchmark(
- "CrashRecovery Diagnosis Overhead (50 runs)", run_recovery_check, 50
+ "CrashRecovery Diagnosis Overhead (50 runs)",
+ run_recovery_check,
+ 50,
)
def benchmark_identity_generation(self, count=20):
@@ -123,7 +127,9 @@ class PerformanceBenchmarker:
RNS.Identity(create_keys=True)
self.record_benchmark(
- f"RNS Identity Generation ({count} identities)", run_gen, count
+ f"RNS Identity Generation ({count} identities)",
+ run_gen,
+ count,
)
def benchmark_identity_listing(self, count=100):
@@ -142,7 +148,9 @@ class PerformanceBenchmarker:
manager.list_identities(current_identity_hash=hashes[0])
self.record_benchmark(
- f"Identity Listing ({count} runs, 10 identities)", run_list, count
+ f"Identity Listing ({count} runs, 10 identities)",
+ run_list,
+ count,
)
@@ -161,7 +169,7 @@ def main():
print("-" * 80)
for r in bench.results:
print(
- f"{r['name']:40} | {r['duration_ms']:8.2f} ms | {r['memory_growth_mb']:8.2f} MB"
+ f"{r['name']:40} | {r['duration_ms']:8.2f} ms | {r['memory_growth_mb']:8.2f} MB",
)
print("=" * 80)
diff --git a/tests/backend/run_comprehensive_benchmarks.py b/tests/backend/run_comprehensive_benchmarks.py
index 40ee07b4..1003bff4 100644
--- a/tests/backend/run_comprehensive_benchmarks.py
+++ b/tests/backend/run_comprehensive_benchmarks.py
@@ -1,18 +1,19 @@
import os
-import sys
-import time
-import shutil
-import tempfile
import random
import secrets
+import shutil
+import sys
+import tempfile
+import time
# Ensure we can import meshchatx
sys.path.append(os.getcwd())
import json
+
from meshchatx.src.backend.database import Database
-from meshchatx.src.backend.identity_manager import IdentityManager
from meshchatx.src.backend.database.telephone import TelephoneDAO
+from meshchatx.src.backend.identity_manager import IdentityManager
from tests.backend.benchmarking_utils import (
benchmark,
get_memory_usage_mb,
@@ -76,7 +77,7 @@ class BackendBenchmarker:
"delivery_attempts": 1,
"title": f"Extreme Msg {b + i}",
"content": secrets.token_bytes(
- 1024
+ 1024,
).hex(), # 2KB hex string
"fields": json.dumps({"test": "data" * 10}),
"timestamp": time.time() - (total_messages - (b + i)),
@@ -87,13 +88,15 @@ class BackendBenchmarker:
}
self.db.messages.upsert_lxmf_message(msg)
print(
- f" Progress: {b + batch_size}/{total_messages} messages inserted..."
+ f" Progress: {b + batch_size}/{total_messages} messages inserted...",
)
@benchmark("EXTREME: Search 100k Messages (Wildcard)", iterations=5)
def run_extreme_search():
return self.db.messages.get_conversation_messages(
- peer_hashes[0], limit=100, offset=50000
+ peer_hashes[0],
+ limit=100,
+ offset=50000,
)
_, res_flood = run_extreme_flood()
@@ -115,7 +118,7 @@ class BackendBenchmarker:
data = {
"destination_hash": secrets.token_hex(16),
"aspect": random.choice(
- ["lxmf.delivery", "lxst.telephony", "group.chat"]
+ ["lxmf.delivery", "lxst.telephony", "group.chat"],
),
"identity_hash": secrets.token_hex(16),
"identity_public_key": secrets.token_hex(32),
@@ -130,7 +133,9 @@ class BackendBenchmarker:
@benchmark("EXTREME: Filter 50k Announces (Complex)", iterations=10)
def run_ann_filter():
return self.db.announces.get_filtered_announces(
- aspect="lxmf.delivery", limit=100, offset=25000
+ aspect="lxmf.delivery",
+ limit=100,
+ offset=25000,
)
_, res_flood = run_ann_flood()
@@ -164,7 +169,8 @@ class BackendBenchmarker:
@benchmark("Database Initialization", iterations=5)
def run():
tmp_db_path = os.path.join(
- self.temp_dir, f"init_test_{random.randint(0, 1000)}.db"
+ self.temp_dir,
+ f"init_test_{random.randint(0, 1000)}.db",
)
db = Database(tmp_db_path)
db.initialize()
@@ -210,7 +216,9 @@ class BackendBenchmarker:
@benchmark("Get Messages for Conversation (offset 500)", iterations=20)
def get_messages():
return self.db.messages.get_conversation_messages(
- peer_hashes[0], limit=50, offset=500
+ peer_hashes[0],
+ limit=50,
+ offset=500,
)
_, res = upsert_batch()
@@ -295,7 +303,7 @@ class BackendBenchmarker:
print(f"{'-' * 40}-|-{'-' * 10}-|-{'-' * 10}")
for r in self.results:
print(
- f"{r.name:40} | {r.duration_ms:8.2f} ms | {r.memory_delta_mb:8.2f} MB"
+ f"{r.name:40} | {r.duration_ms:8.2f} ms | {r.memory_delta_mb:8.2f} MB",
)
print(f"{'=' * 59}")
print(f"Final Memory Usage: {get_memory_usage_mb():.2f} MB")
@@ -306,7 +314,9 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser(description="MeshChatX Backend Benchmarker")
parser.add_argument(
- "--extreme", action="store_true", help="Run extreme stress tests"
+ "--extreme",
+ action="store_true",
+ help="Run extreme stress tests",
)
args = parser.parse_args()
diff --git a/tests/backend/test_announce_dao_extended.py b/tests/backend/test_announce_dao_extended.py
index 522fb8a1..b3d9aff9 100644
--- a/tests/backend/test_announce_dao_extended.py
+++ b/tests/backend/test_announce_dao_extended.py
@@ -1,9 +1,11 @@
import os
import tempfile
+
import pytest
+
+from meshchatx.src.backend.database.announces import AnnounceDAO
from meshchatx.src.backend.database.provider import DatabaseProvider
from meshchatx.src.backend.database.schema import DatabaseSchema
-from meshchatx.src.backend.database.announces import AnnounceDAO
@pytest.fixture
@@ -37,7 +39,7 @@ def test_get_filtered_announces_identity_hash(announce_dao):
"rssi": -50,
"snr": 10,
"quality": 1.0,
- }
+ },
)
announce_dao.upsert_announce(
{
@@ -49,7 +51,7 @@ def test_get_filtered_announces_identity_hash(announce_dao):
"rssi": -50,
"snr": 10,
"quality": 1.0,
- }
+ },
)
announce_dao.upsert_announce(
{
@@ -61,7 +63,7 @@ def test_get_filtered_announces_identity_hash(announce_dao):
"rssi": -50,
"snr": 10,
"quality": 1.0,
- }
+ },
)
# Test filtering by identity_hash
@@ -71,7 +73,8 @@ def test_get_filtered_announces_identity_hash(announce_dao):
# Test filtering by identity_hash and aspect
results = announce_dao.get_filtered_announces(
- identity_hash="ident1", aspect="lxmf.propagation"
+ identity_hash="ident1",
+ aspect="lxmf.propagation",
)
assert len(results) == 1
assert results[0]["destination_hash"] == "dest1"
@@ -89,6 +92,7 @@ def test_get_filtered_announces_robustness(announce_dao):
# Test with multiple filters that yield no results
results = announce_dao.get_filtered_announces(
- identity_hash="ident1", aspect="non_existent_aspect"
+ identity_hash="ident1",
+ aspect="non_existent_aspect",
)
assert len(results) == 0
diff --git a/tests/backend/test_app_endpoints.py b/tests/backend/test_app_endpoints.py
index f2172cd1..823d78e2 100644
--- a/tests/backend/test_app_endpoints.py
+++ b/tests/backend/test_app_endpoints.py
@@ -1,11 +1,13 @@
+import asyncio
+import json
import shutil
import tempfile
-import pytest
-import json
from unittest.mock import MagicMock, patch
-from meshchatx.meshchat import ReticulumMeshChat
+
+import pytest
import RNS
-import asyncio
+
+from meshchatx.meshchat import ReticulumMeshChat
@pytest.fixture
diff --git a/tests/backend/test_app_status_tracking.py b/tests/backend/test_app_status_tracking.py
index 1d3b829a..4235579d 100644
--- a/tests/backend/test_app_status_tracking.py
+++ b/tests/backend/test_app_status_tracking.py
@@ -1,10 +1,12 @@
import shutil
import tempfile
-import pytest
from unittest.mock import MagicMock, patch
-from meshchatx.meshchat import ReticulumMeshChat
+
+import pytest
import RNS
+from meshchatx.meshchat import ReticulumMeshChat
+
@pytest.fixture
def temp_dir():
@@ -36,43 +38,43 @@ async def test_app_status_endpoints(mock_rns_minimal, temp_dir):
with ExitStack() as stack:
# Patch all dependencies
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.MessageHandler")
+ patch("meshchatx.src.backend.identity_context.MessageHandler"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.AnnounceManager")
+ patch("meshchatx.src.backend.identity_context.AnnounceManager"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.ArchiverManager")
+ patch("meshchatx.src.backend.identity_context.ArchiverManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
stack.enter_context(patch("meshchatx.src.backend.identity_context.DocsManager"))
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.NomadNetworkManager")
+ patch("meshchatx.src.backend.identity_context.NomadNetworkManager"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.TelephoneManager")
+ patch("meshchatx.src.backend.identity_context.TelephoneManager"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.VoicemailManager")
+ patch("meshchatx.src.backend.identity_context.VoicemailManager"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.RingtoneManager")
+ patch("meshchatx.src.backend.identity_context.RingtoneManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.RNStatusHandler")
+ patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.RNProbeHandler")
+ patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.TranslatorHandler")
+ patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager")
+ patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
)
stack.enter_context(
- patch("meshchatx.src.backend.sideband_commands.SidebandCommands")
+ patch("meshchatx.src.backend.sideband_commands.SidebandCommands"),
)
stack.enter_context(patch("meshchatx.meshchat.Telemeter"))
stack.enter_context(patch("meshchatx.meshchat.CrashRecovery"))
diff --git a/tests/backend/test_backend_integrity.py b/tests/backend/test_backend_integrity.py
index 2b5ad630..4b7302b1 100644
--- a/tests/backend/test_backend_integrity.py
+++ b/tests/backend/test_backend_integrity.py
@@ -1,9 +1,9 @@
-import unittest
+import hashlib
+import json
import os
import shutil
import tempfile
-import json
-import hashlib
+import unittest
from pathlib import Path
@@ -55,7 +55,7 @@ class TestBackendIntegrity(unittest.TestCase):
def test_manifest_generation(self):
"""Test that the build script logic produces a valid manifest."""
manifest_path = self.generate_manifest()
- with open(manifest_path, "r") as f:
+ with open(manifest_path) as f:
manifest = json.load(f)
self.assertEqual(len(manifest["files"]), 2)
@@ -66,7 +66,7 @@ class TestBackendIntegrity(unittest.TestCase):
def test_tampering_detection_logic(self):
"""Test that modifying a file changes its hash (logic check)."""
manifest_path = self.generate_manifest()
- with open(manifest_path, "r") as f:
+ with open(manifest_path) as f:
manifest = json.load(f)
old_hash = manifest["files"]["ReticulumMeshChatX"]
diff --git a/tests/backend/test_blackhole_logic.py b/tests/backend/test_blackhole_logic.py
index a9be6cac..569ac90d 100644
--- a/tests/backend/test_blackhole_logic.py
+++ b/tests/backend/test_blackhole_logic.py
@@ -1,11 +1,13 @@
+import json
import shutil
import tempfile
+from unittest.mock import AsyncMock, MagicMock, patch
+
import pytest
-import json
-from unittest.mock import MagicMock, patch, AsyncMock
-from meshchatx.meshchat import ReticulumMeshChat
import RNS
+from meshchatx.meshchat import ReticulumMeshChat
+
@pytest.fixture
def temp_dir():
@@ -74,7 +76,7 @@ async def test_banish_identity_with_blackhole(mock_rns_minimal, temp_dir):
# Verify DB call
app_instance.database.misc.add_blocked_destination.assert_called_with(
- target_hash
+ target_hash,
)
# Verify RNS blackhole call
@@ -100,7 +102,7 @@ async def test_banish_identity_with_resolution(mock_rns_minimal, temp_dir):
# Mock identity resolution
app_instance.database.announces.get_announce_by_hash.return_value = {
- "identity_hash": ident_hash
+ "identity_hash": ident_hash,
}
request = MagicMock()
@@ -147,7 +149,7 @@ async def test_banish_identity_disabled_integration(mock_rns_minimal, temp_dir):
# DB call should still happen
app_instance.database.misc.add_blocked_destination.assert_called_with(
- target_hash
+ target_hash,
)
# RNS blackhole call should NOT happen
@@ -189,7 +191,7 @@ async def test_lift_banishment(mock_rns_minimal, temp_dir):
# Verify DB call
app_instance.database.misc.delete_blocked_destination.assert_called_with(
- target_hash
+ target_hash,
)
# Verify RNS unblackhole call
@@ -213,7 +215,7 @@ async def test_get_blackhole_list(mock_rns_minimal, temp_dir):
"source": b"\x02" * 32,
"until": 1234567890,
"reason": "Spam",
- }
+ },
}
request = MagicMock()
diff --git a/tests/backend/test_community_interfaces.py b/tests/backend/test_community_interfaces.py
index 74eca084..94070d2e 100644
--- a/tests/backend/test_community_interfaces.py
+++ b/tests/backend/test_community_interfaces.py
@@ -1,5 +1,7 @@
-import pytest
from unittest.mock import MagicMock, patch
+
+import pytest
+
from meshchatx.src.backend.community_interfaces import CommunityInterfacesManager
from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
@@ -42,7 +44,7 @@ async def test_rnstatus_integration_simulated():
"rxb": 0,
"txb": 0,
},
- ]
+ ],
}
handler = RNStatusHandler(mock_reticulum)
diff --git a/tests/backend/test_contacts_custom_image.py b/tests/backend/test_contacts_custom_image.py
index 374ad011..429f39f3 100644
--- a/tests/backend/test_contacts_custom_image.py
+++ b/tests/backend/test_contacts_custom_image.py
@@ -1,8 +1,10 @@
import os
+
import pytest
+
+from meshchatx.src.backend.database.contacts import ContactsDAO
from meshchatx.src.backend.database.provider import DatabaseProvider
from meshchatx.src.backend.database.schema import DatabaseSchema
-from meshchatx.src.backend.database.contacts import ContactsDAO
@pytest.fixture
@@ -39,7 +41,8 @@ def test_contacts_with_custom_image(db_provider):
# Test updating contact image
contacts_dao.update_contact(
- contact["id"], custom_image="data:image/png;base64,updateddata"
+ contact["id"],
+ custom_image="data:image/png;base64,updateddata",
)
contact = contacts_dao.get_contact(contact["id"])
diff --git a/tests/backend/test_crash_recovery.py b/tests/backend/test_crash_recovery.py
index f17345d1..cab6877a 100644
--- a/tests/backend/test_crash_recovery.py
+++ b/tests/backend/test_crash_recovery.py
@@ -1,10 +1,11 @@
-import unittest
+import io
import os
import shutil
-import tempfile
-import sys
-import io
import sqlite3
+import sys
+import tempfile
+import unittest
+
from meshchatx.src.backend.recovery.crash_recovery import CrashRecovery
diff --git a/tests/backend/test_database_robustness.py b/tests/backend/test_database_robustness.py
index 62663ffb..dc2a9964 100644
--- a/tests/backend/test_database_robustness.py
+++ b/tests/backend/test_database_robustness.py
@@ -1,7 +1,8 @@
-import unittest
import os
-import tempfile
import shutil
+import tempfile
+import unittest
+
from meshchatx.src.backend.database.provider import DatabaseProvider
from meshchatx.src.backend.database.schema import DatabaseSchema
@@ -42,7 +43,8 @@ class TestDatabaseRobustness(unittest.TestCase):
)
""")
self.provider.execute(
- "INSERT INTO config (key, value) VALUES (?, ?)", ("database_version", "1")
+ "INSERT INTO config (key, value) VALUES (?, ?)",
+ ("database_version", "1"),
)
# 3. Attempt initialization.
@@ -77,7 +79,7 @@ class TestDatabaseRobustness(unittest.TestCase):
# 3. Version should now be set to LATEST
row = self.provider.fetchone(
- "SELECT value FROM config WHERE key = 'database_version'"
+ "SELECT value FROM config WHERE key = 'database_version'",
)
self.assertEqual(int(row["value"]), self.schema.LATEST_VERSION)
diff --git a/tests/backend/test_database_snapshots.py b/tests/backend/test_database_snapshots.py
index 1f09c698..49f29ca5 100644
--- a/tests/backend/test_database_snapshots.py
+++ b/tests/backend/test_database_snapshots.py
@@ -3,6 +3,7 @@ import shutil
import tempfile
import pytest
+
from meshchatx.src.backend.database import Database
@@ -20,7 +21,8 @@ def test_database_snapshot_creation(temp_dir):
# Add some data
db.execute_sql(
- "INSERT INTO config (key, value) VALUES (?, ?)", ("test_key", "test_value")
+ "INSERT INTO config (key, value) VALUES (?, ?)",
+ ("test_key", "test_value"),
)
# Create snapshot
diff --git a/tests/backend/test_debug_logs.py b/tests/backend/test_debug_logs.py
index 370ab297..ef7a260a 100644
--- a/tests/backend/test_debug_logs.py
+++ b/tests/backend/test_debug_logs.py
@@ -1,8 +1,10 @@
+import logging
import time
+
import pytest
-import logging
-from meshchatx.src.backend.persistent_log_handler import PersistentLogHandler
+
from meshchatx.src.backend.database import Database
+from meshchatx.src.backend.persistent_log_handler import PersistentLogHandler
@pytest.fixture
diff --git a/tests/backend/test_docs_manager.py b/tests/backend/test_docs_manager.py
index a1fc2283..c896ea99 100644
--- a/tests/backend/test_docs_manager.py
+++ b/tests/backend/test_docs_manager.py
@@ -141,7 +141,7 @@ def create_mock_zip(zip_path, file_list):
)
@given(
root_folder_name=st.text(min_size=1, max_size=50).filter(
- lambda x: "/" not in x and x not in [".", ".."]
+ lambda x: "/" not in x and x not in [".", ".."],
),
docs_file=st.text(min_size=1, max_size=50).filter(lambda x: "/" not in x),
)
diff --git a/tests/backend/test_emergency_mode.py b/tests/backend/test_emergency_mode.py
index 2258d74e..d98538d5 100644
--- a/tests/backend/test_emergency_mode.py
+++ b/tests/backend/test_emergency_mode.py
@@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch
import pytest
import RNS
+
from meshchatx.meshchat import ReticulumMeshChat
@@ -39,7 +40,9 @@ def mock_rns():
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
patch.object(
- MockIdentityClass, "from_bytes", return_value=mock_id_instance
+ MockIdentityClass,
+ "from_bytes",
+ return_value=mock_id_instance,
),
):
mock_transport.interfaces = []
@@ -73,7 +76,7 @@ def test_emergency_mode_startup_logic(mock_rns, temp_dir):
patch("meshchatx.src.backend.identity_context.DocsManager"),
patch("meshchatx.src.backend.identity_context.NomadNetworkManager"),
patch(
- "meshchatx.src.backend.identity_context.TelephoneManager"
+ "meshchatx.src.backend.identity_context.TelephoneManager",
) as mock_tel_class,
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
@@ -83,10 +86,10 @@ def test_emergency_mode_startup_logic(mock_rns, temp_dir):
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
patch(
- "meshchatx.src.backend.identity_context.IntegrityManager"
+ "meshchatx.src.backend.identity_context.IntegrityManager",
) as mock_integrity_class,
patch(
- "meshchatx.src.backend.identity_context.IdentityContext.start_background_threads"
+ "meshchatx.src.backend.identity_context.IdentityContext.start_background_threads",
),
):
# Initialize app in emergency mode
@@ -139,7 +142,7 @@ def test_emergency_mode_env_var(mock_rns, temp_dir):
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
patch(
- "meshchatx.src.backend.identity_context.IdentityContext.start_background_threads"
+ "meshchatx.src.backend.identity_context.IdentityContext.start_background_threads",
),
):
# We need to simulate the argparse processing that happens in main()
@@ -170,7 +173,7 @@ def test_normal_mode_startup_logic(mock_rns, temp_dir):
patch("meshchatx.src.backend.identity_context.DocsManager"),
patch("meshchatx.src.backend.identity_context.NomadNetworkManager"),
patch(
- "meshchatx.src.backend.identity_context.TelephoneManager"
+ "meshchatx.src.backend.identity_context.TelephoneManager",
) as mock_tel_class,
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
@@ -180,10 +183,10 @@ def test_normal_mode_startup_logic(mock_rns, temp_dir):
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
patch(
- "meshchatx.src.backend.identity_context.IntegrityManager"
+ "meshchatx.src.backend.identity_context.IntegrityManager",
) as mock_integrity_class,
patch(
- "meshchatx.src.backend.identity_context.IdentityContext.start_background_threads"
+ "meshchatx.src.backend.identity_context.IdentityContext.start_background_threads",
),
):
# Configure mocks BEFORE instantiating app
diff --git a/tests/backend/test_fuzzing.py b/tests/backend/test_fuzzing.py
index 24950ecd..708d8031 100644
--- a/tests/backend/test_fuzzing.py
+++ b/tests/backend/test_fuzzing.py
@@ -12,6 +12,11 @@ from hypothesis import strategies as st
from meshchatx.meshchat import ReticulumMeshChat
from meshchatx.src.backend.interface_config_parser import InterfaceConfigParser
+from meshchatx.src.backend.lxmf_message_fields import (
+ LxmfAudioField,
+ LxmfFileAttachment,
+ LxmfImageField,
+)
from meshchatx.src.backend.meshchat_utils import (
parse_lxmf_display_name,
parse_nomadnetwork_node_display_name,
@@ -20,11 +25,6 @@ from meshchatx.src.backend.nomadnet_utils import (
convert_nomadnet_field_data_to_map,
convert_nomadnet_string_data_to_map,
)
-from meshchatx.src.backend.lxmf_message_fields import (
- LxmfAudioField,
- LxmfFileAttachment,
- LxmfImageField,
-)
from meshchatx.src.backend.telemetry_utils import Telemeter
@@ -122,39 +122,39 @@ def mock_app(temp_dir):
# Mock database and other managers to avoid heavy initialization
stack.enter_context(patch("meshchatx.src.backend.identity_context.Database"))
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.ConfigManager")
+ patch("meshchatx.src.backend.identity_context.ConfigManager"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.MessageHandler")
+ patch("meshchatx.src.backend.identity_context.MessageHandler"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.AnnounceManager")
+ patch("meshchatx.src.backend.identity_context.AnnounceManager"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.ArchiverManager")
+ patch("meshchatx.src.backend.identity_context.ArchiverManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.TelephoneManager")
+ patch("meshchatx.src.backend.identity_context.TelephoneManager"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.VoicemailManager")
+ patch("meshchatx.src.backend.identity_context.VoicemailManager"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.RingtoneManager")
+ patch("meshchatx.src.backend.identity_context.RingtoneManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.RNStatusHandler")
+ patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.RNProbeHandler")
+ patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.TranslatorHandler")
+ patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager")
+ patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
)
mock_async_utils = stack.enter_context(patch("meshchatx.meshchat.AsyncUtils"))
stack.enter_context(patch("LXMF.LXMRouter"))
@@ -171,7 +171,9 @@ def mock_app(temp_dir):
stack.enter_context(patch("threading.Thread"))
stack.enter_context(
patch.object(
- ReticulumMeshChat, "announce_loop", new=MagicMock(return_value=None)
+ ReticulumMeshChat,
+ "announce_loop",
+ new=MagicMock(return_value=None),
),
)
stack.enter_context(
@@ -183,12 +185,16 @@ def mock_app(temp_dir):
)
stack.enter_context(
patch.object(
- ReticulumMeshChat, "crawler_loop", new=MagicMock(return_value=None)
+ ReticulumMeshChat,
+ "crawler_loop",
+ new=MagicMock(return_value=None),
),
)
stack.enter_context(
patch.object(
- ReticulumMeshChat, "auto_backup_loop", new=MagicMock(return_value=None)
+ ReticulumMeshChat,
+ "auto_backup_loop",
+ new=MagicMock(return_value=None),
),
)
@@ -196,13 +202,13 @@ def mock_app(temp_dir):
mock_id.get_private_key = MagicMock(return_value=b"test_private_key")
stack.enter_context(
- patch.object(MockIdentityClass, "from_file", return_value=mock_id)
+ patch.object(MockIdentityClass, "from_file", return_value=mock_id),
)
stack.enter_context(
- patch.object(MockIdentityClass, "recall", return_value=mock_id)
+ patch.object(MockIdentityClass, "recall", return_value=mock_id),
)
stack.enter_context(
- patch.object(MockIdentityClass, "from_bytes", return_value=mock_id)
+ patch.object(MockIdentityClass, "from_bytes", return_value=mock_id),
)
# Make run_async a no-op that doesn't trigger coroutine warnings
diff --git a/tests/backend/test_fuzzing_extended.py b/tests/backend/test_fuzzing_extended.py
index 22748d28..1d8850bd 100644
--- a/tests/backend/test_fuzzing_extended.py
+++ b/tests/backend/test_fuzzing_extended.py
@@ -29,39 +29,39 @@ def mock_app(temp_dir):
with ExitStack() as stack:
stack.enter_context(patch("meshchatx.src.backend.identity_context.Database"))
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.ConfigManager")
+ patch("meshchatx.src.backend.identity_context.ConfigManager"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.MessageHandler")
+ patch("meshchatx.src.backend.identity_context.MessageHandler"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.AnnounceManager")
+ patch("meshchatx.src.backend.identity_context.AnnounceManager"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.ArchiverManager")
+ patch("meshchatx.src.backend.identity_context.ArchiverManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.TelephoneManager")
+ patch("meshchatx.src.backend.identity_context.TelephoneManager"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.VoicemailManager")
+ patch("meshchatx.src.backend.identity_context.VoicemailManager"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.RingtoneManager")
+ patch("meshchatx.src.backend.identity_context.RingtoneManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.RNStatusHandler")
+ patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.RNProbeHandler")
+ patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.TranslatorHandler")
+ patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager")
+ patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
)
stack.enter_context(patch("meshchatx.meshchat.AsyncUtils"))
stack.enter_context(patch("LXMF.LXMRouter"))
@@ -76,31 +76,37 @@ def mock_app(temp_dir):
stack.enter_context(patch("threading.Thread"))
stack.enter_context(
patch.object(
- ReticulumMeshChat, "announce_loop", new=MagicMock(return_value=None)
- )
+ ReticulumMeshChat,
+ "announce_loop",
+ new=MagicMock(return_value=None),
+ ),
)
stack.enter_context(
patch.object(
ReticulumMeshChat,
"announce_sync_propagation_nodes",
new=MagicMock(return_value=None),
- )
+ ),
)
stack.enter_context(
patch.object(
- ReticulumMeshChat, "crawler_loop", new=MagicMock(return_value=None)
- )
+ ReticulumMeshChat,
+ "crawler_loop",
+ new=MagicMock(return_value=None),
+ ),
)
stack.enter_context(
patch.object(
- ReticulumMeshChat, "auto_backup_loop", new=MagicMock(return_value=None)
- )
+ ReticulumMeshChat,
+ "auto_backup_loop",
+ new=MagicMock(return_value=None),
+ ),
)
mock_id = MockIdentityClass()
mock_id.get_private_key = MagicMock(return_value=b"test_private_key")
stack.enter_context(
- patch.object(MockIdentityClass, "from_file", return_value=mock_id)
+ patch.object(MockIdentityClass, "from_file", return_value=mock_id),
)
app = ReticulumMeshChat(
@@ -136,9 +142,10 @@ def mock_app(temp_dir):
data=st.recursive(
st.one_of(st.none(), st.booleans(), st.floats(), st.text(), st.integers()),
lambda children: st.one_of(
- st.lists(children), st.dictionaries(st.text(), children)
+ st.lists(children),
+ st.dictionaries(st.text(), children),
),
- )
+ ),
)
@pytest.mark.asyncio
async def test_websocket_api_recursive_fuzzing(mock_app, data):
@@ -190,7 +197,8 @@ async def test_lxm_uri_parsing_fuzzing(mock_app, uri):
# Also test it through the websocket interface if it exists there
mock_client = MagicMock()
await mock_app.on_websocket_data_received(
- mock_client, {"type": "lxm.ingest_uri", "uri": uri}
+ mock_client,
+ {"type": "lxm.ingest_uri", "uri": uri},
)
except (KeyError, TypeError, ValueError, AttributeError):
pass
@@ -232,7 +240,8 @@ def test_lxmf_message_construction_fuzzing(mock_app, content, title, fields):
@given(
table_name=st.sampled_from(["messages", "announces", "identities", "config"]),
data=st.dictionaries(
- st.text(), st.one_of(st.text(), st.integers(), st.binary(), st.none())
+ st.text(),
+ st.one_of(st.text(), st.integers(), st.binary(), st.none()),
),
)
def test_database_record_fuzzing(mock_app, table_name, data):
@@ -266,10 +275,10 @@ def test_database_record_fuzzing(mock_app, table_name, data):
"map_default_lat",
"map_default_lon",
"lxmf_inbound_stamp_cost",
- ]
+ ],
),
st.one_of(st.text(), st.integers(), st.booleans(), st.none()),
- )
+ ),
)
@pytest.mark.asyncio
async def test_config_update_fuzzing(mock_app, config_updates):
@@ -288,7 +297,10 @@ async def test_config_update_fuzzing(mock_app, config_updates):
@given(destination_hash=st.text(), content=st.text(), title=st.text())
@pytest.mark.asyncio
async def test_lxm_generate_paper_uri_fuzzing(
- mock_app, destination_hash, content, title
+ mock_app,
+ destination_hash,
+ content,
+ title,
):
"""Fuzz lxm.generate_paper_uri WebSocket handler."""
mock_client = MagicMock()
@@ -410,7 +422,10 @@ def test_on_lxmf_delivery_fuzzing(mock_app, content, title):
app_data=st.binary(min_size=0, max_size=1000),
)
def test_on_lxmf_announce_received_fuzzing(
- mock_app, aspect, destination_hash, app_data
+ mock_app,
+ aspect,
+ destination_hash,
+ app_data,
):
"""Fuzz the announce received handler."""
try:
@@ -457,7 +472,10 @@ def test_telemeter_roundtrip_fuzzing(battery, uptime, load, temperature):
try:
t = Telemeter(
- battery=battery, uptime=uptime, load=load, temperature=temperature
+ battery=battery,
+ uptime=uptime,
+ load=load,
+ temperature=temperature,
)
packed = t.pack()
unpacked = Telemeter.from_packed(packed)
diff --git a/tests/backend/test_identity_switch.py b/tests/backend/test_identity_switch.py
index c5ad7d1e..32552f37 100644
--- a/tests/backend/test_identity_switch.py
+++ b/tests/backend/test_identity_switch.py
@@ -1,8 +1,9 @@
import os
import shutil
import tempfile
-from unittest.mock import AsyncMock, MagicMock, patch
from contextlib import ExitStack
+from unittest.mock import AsyncMock, MagicMock, patch
+
import pytest
import RNS
@@ -64,17 +65,19 @@ def mock_rns():
# Mock class methods on MockIdentityClass
mock_id_instance = MockIdentityClass()
mock_id_instance.get_private_key = MagicMock(
- return_value=b"initial_private_key"
+ return_value=b"initial_private_key",
)
stack.enter_context(
- patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance)
+ patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
)
stack.enter_context(
- patch.object(MockIdentityClass, "recall", return_value=mock_id_instance)
+ patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
)
stack.enter_context(
- patch.object(MockIdentityClass, "from_bytes", return_value=mock_id_instance)
+ patch.object(
+ MockIdentityClass, "from_bytes", return_value=mock_id_instance
+ ),
)
# Access specifically the ones we need to configure
@@ -118,7 +121,7 @@ async def test_hotswap_identity_success(mock_rns, temp_dir):
# Mock methods
app.teardown_identity = MagicMock()
app.setup_identity = MagicMock(
- side_effect=lambda id: setattr(app, "current_context", mock_context)
+ side_effect=lambda id: setattr(app, "current_context", mock_context),
)
app.websocket_broadcast = AsyncMock()
@@ -164,7 +167,7 @@ async def test_hotswap_identity_keep_alive(mock_rns, temp_dir):
# Mock methods
app.teardown_identity = MagicMock()
app.setup_identity = MagicMock(
- side_effect=lambda id: setattr(app, "current_context", mock_context)
+ side_effect=lambda id: setattr(app, "current_context", mock_context),
)
app.websocket_broadcast = AsyncMock()
diff --git a/tests/backend/test_integrity.py b/tests/backend/test_integrity.py
index 39eef39a..ed53738b 100644
--- a/tests/backend/test_integrity.py
+++ b/tests/backend/test_integrity.py
@@ -1,7 +1,8 @@
-import unittest
import shutil
import tempfile
+import unittest
from pathlib import Path
+
from meshchatx.src.backend.integrity_manager import IntegrityManager
diff --git a/tests/backend/test_interface_discovery.py b/tests/backend/test_interface_discovery.py
new file mode 100644
index 00000000..ab919a75
--- /dev/null
+++ b/tests/backend/test_interface_discovery.py
@@ -0,0 +1,201 @@
+import json
+import shutil
+import tempfile
+from unittest.mock import MagicMock, patch
+
+import pytest
+import RNS
+
+from meshchatx.meshchat import ReticulumMeshChat
+
+
+class ConfigDict(dict):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.write_called = False
+
+ def write(self):
+ self.write_called = True
+ return True
+
+
+@pytest.fixture
+def temp_dir():
+ path = tempfile.mkdtemp()
+ try:
+ yield path
+ finally:
+ shutil.rmtree(path)
+
+
+def build_identity():
+ identity = MagicMock(spec=RNS.Identity)
+ identity.hash = b"test_hash_32_bytes_long_01234567"
+ identity.hexhash = identity.hash.hex()
+ identity.get_private_key.return_value = b"test_private_key"
+ return identity
+
+
+async def find_route_handler(app_instance, path, method):
+ for route in app_instance.get_routes():
+ if route.path == path and route.method == method:
+ return route.handler
+ return None
+
+
+@pytest.mark.asyncio
+async def test_reticulum_discovery_get_and_patch(temp_dir):
+ config = ConfigDict(
+ {
+ "reticulum": {
+ "discover_interfaces": "true",
+ "interface_discovery_sources": "abc,def",
+ "required_discovery_value": "16",
+ "autoconnect_discovered_interfaces": "2",
+ "network_identity": "/tmp/net_id",
+ },
+ "interfaces": {},
+ },
+ )
+
+ with (
+ patch("meshchatx.meshchat.generate_ssl_certificate"),
+ patch("RNS.Reticulum") as mock_rns,
+ patch("RNS.Transport"),
+ patch("LXMF.LXMRouter"),
+ ):
+ mock_reticulum = mock_rns.return_value
+ mock_reticulum.config = config
+ mock_reticulum.configpath = "/tmp/mock_config"
+ mock_reticulum.is_connected_to_shared_instance = False
+ mock_reticulum.transport_enabled.return_value = True
+
+ app_instance = ReticulumMeshChat(
+ identity=build_identity(),
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ )
+
+ get_handler = await find_route_handler(
+ app_instance,
+ "/api/v1/reticulum/discovery",
+ "GET",
+ )
+ patch_handler = await find_route_handler(
+ app_instance,
+ "/api/v1/reticulum/discovery",
+ "PATCH",
+ )
+ assert get_handler and patch_handler
+
+ # GET returns current reticulum discovery config
+ get_response = await get_handler(MagicMock())
+ get_data = json.loads(get_response.body)
+ assert get_data["discovery"]["discover_interfaces"] == "true"
+ assert get_data["discovery"]["interface_discovery_sources"] == "abc,def"
+ assert get_data["discovery"]["required_discovery_value"] == "16"
+ assert get_data["discovery"]["autoconnect_discovered_interfaces"] == "2"
+ assert get_data["discovery"]["network_identity"] == "/tmp/net_id"
+
+ # PATCH updates and persists
+ new_config = {
+ "discover_interfaces": False,
+ "interface_discovery_sources": "",
+ "required_discovery_value": 18,
+ "autoconnect_discovered_interfaces": 5,
+ "network_identity": "/tmp/other_id",
+ }
+
+ class PatchRequest:
+ @staticmethod
+ async def json():
+ return new_config
+
+ patch_response = await patch_handler(PatchRequest())
+ patch_data = json.loads(patch_response.body)
+ assert patch_data["discovery"]["discover_interfaces"] is False
+ assert patch_data["discovery"]["interface_discovery_sources"] is None
+ assert patch_data["discovery"]["required_discovery_value"] == 18
+ assert patch_data["discovery"]["autoconnect_discovered_interfaces"] == 5
+ assert patch_data["discovery"]["network_identity"] == "/tmp/other_id"
+ assert config["reticulum"]["discover_interfaces"] is False
+ assert "interface_discovery_sources" not in config["reticulum"]
+ assert config["reticulum"]["required_discovery_value"] == 18
+ assert config["reticulum"]["autoconnect_discovered_interfaces"] == 5
+ assert config["reticulum"]["network_identity"] == "/tmp/other_id"
+ assert config.write_called
+
+
+@pytest.mark.asyncio
+async def test_interface_add_includes_discovery_fields(temp_dir):
+ config = ConfigDict({"reticulum": {}, "interfaces": {}})
+
+ with (
+ patch("meshchatx.meshchat.generate_ssl_certificate"),
+ patch("RNS.Reticulum") as mock_rns,
+ patch("RNS.Transport"),
+ patch("LXMF.LXMRouter"),
+ ):
+ mock_reticulum = mock_rns.return_value
+ mock_reticulum.config = config
+ mock_reticulum.configpath = "/tmp/mock_config"
+ mock_reticulum.is_connected_to_shared_instance = False
+ mock_reticulum.transport_enabled.return_value = True
+
+ app_instance = ReticulumMeshChat(
+ identity=build_identity(),
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ )
+
+ add_handler = await find_route_handler(
+ app_instance,
+ "/api/v1/reticulum/interfaces/add",
+ "POST",
+ )
+ assert add_handler
+
+ payload = {
+ "allow_overwriting_interface": False,
+ "name": "TestIface",
+ "type": "TCPClientInterface",
+ "target_host": "example.com",
+ "target_port": "4242",
+ "discoverable": "yes",
+ "discovery_name": "Region A",
+ "announce_interval": 720,
+ "reachable_on": "/usr/bin/get_ip.sh",
+ "discovery_stamp_value": 22,
+ "discovery_encrypt": True,
+ "publish_ifac": True,
+ "latitude": 10.1,
+ "longitude": 20.2,
+ "height": 30,
+ "discovery_frequency": 915000000,
+ "discovery_bandwidth": 125000,
+ "discovery_modulation": "LoRa",
+ }
+
+ class AddRequest:
+ @staticmethod
+ async def json():
+ return payload
+
+ response = await add_handler(AddRequest())
+ data = json.loads(response.body)
+ assert "Interface has been added" in data["message"]
+ saved = config["interfaces"]["TestIface"]
+ assert saved["discoverable"] == "yes"
+ assert saved["discovery_name"] == "Region A"
+ assert saved["announce_interval"] == 720
+ assert saved["reachable_on"] == "/usr/bin/get_ip.sh"
+ assert saved["discovery_stamp_value"] == 22
+ assert saved["discovery_encrypt"] is True
+ assert saved["publish_ifac"] is True
+ assert saved["latitude"] == 10.1
+ assert saved["longitude"] == 20.2
+ assert saved["height"] == 30
+ assert saved["discovery_frequency"] == 915000000
+ assert saved["discovery_bandwidth"] == 125000
+ assert saved["discovery_modulation"] == "LoRa"
+ assert config.write_called
diff --git a/tests/backend/test_lxmf_attachments.py b/tests/backend/test_lxmf_attachments.py
index 872a0e88..847eaa61 100644
--- a/tests/backend/test_lxmf_attachments.py
+++ b/tests/backend/test_lxmf_attachments.py
@@ -1,4 +1,5 @@
import json
+
from meshchatx.src.backend.meshchat_utils import message_fields_have_attachments
@@ -22,7 +23,7 @@ def test_message_fields_have_attachments():
# File attachments - with files
assert (
message_fields_have_attachments(
- json.dumps({"file_attachments": [{"file_name": "test.txt"}]})
+ json.dumps({"file_attachments": [{"file_name": "test.txt"}]}),
)
is True
)
@@ -36,8 +37,8 @@ def test_message_fields_have_attachments_mixed():
assert (
message_fields_have_attachments(
json.dumps(
- {"image": "img", "file_attachments": [{"file_name": "test.txt"}]}
- )
+ {"image": "img", "file_attachments": [{"file_name": "test.txt"}]},
+ ),
)
is True
)
@@ -45,7 +46,7 @@ def test_message_fields_have_attachments_mixed():
# Unrelated fields
assert (
message_fields_have_attachments(
- json.dumps({"title": "hello", "content": "world"})
+ json.dumps({"title": "hello", "content": "world"}),
)
is False
)
diff --git a/tests/backend/test_lxmf_icons.py b/tests/backend/test_lxmf_icons.py
index 4067a693..a7836912 100644
--- a/tests/backend/test_lxmf_icons.py
+++ b/tests/backend/test_lxmf_icons.py
@@ -1,10 +1,11 @@
import shutil
import tempfile
-from unittest.mock import AsyncMock, MagicMock, patch
from contextlib import ExitStack
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import LXMF
import pytest
import RNS
-import LXMF
from meshchatx.meshchat import ReticulumMeshChat
@@ -80,17 +81,19 @@ def mock_rns():
# Mock class methods on MockIdentityClass
mock_id_instance = MockIdentityClass()
mock_id_instance.get_private_key = MagicMock(
- return_value=b"initial_private_key"
+ return_value=b"initial_private_key",
)
stack.enter_context(
- patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance)
+ patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
)
stack.enter_context(
- patch.object(MockIdentityClass, "recall", return_value=mock_id_instance)
+ patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
)
stack.enter_context(
- patch.object(MockIdentityClass, "from_bytes", return_value=mock_id_instance)
+ patch.object(
+ MockIdentityClass, "from_bytes", return_value=mock_id_instance
+ ),
)
# Setup mock LXMessage
@@ -249,7 +252,7 @@ async def test_receive_message_updates_icon(mock_rns, temp_dir):
"new_icon",
b"\xff\xff\xff", # #ffffff
b"\x00\x00\x00", # #000000
- ]
+ ],
}
# Mock methods
diff --git a/tests/backend/test_lxmf_propagation_full.py b/tests/backend/test_lxmf_propagation_full.py
index feae74c4..4766c254 100644
--- a/tests/backend/test_lxmf_propagation_full.py
+++ b/tests/backend/test_lxmf_propagation_full.py
@@ -1,11 +1,13 @@
+import json
import shutil
import tempfile
-import pytest
-import json
from unittest.mock import MagicMock, patch
-from meshchatx.meshchat import ReticulumMeshChat
-import RNS
+
import LXMF
+import pytest
+import RNS
+
+from meshchatx.meshchat import ReticulumMeshChat
# Store original constants
PR_IDLE = LXMF.LXMRouter.PR_IDLE
@@ -58,7 +60,7 @@ def mock_app(temp_dir):
mock_rns_inst.transport_enabled.return_value = False
with patch(
- "meshchatx.src.backend.meshchat_utils.LXMRouter"
+ "meshchatx.src.backend.meshchat_utils.LXMRouter",
) as mock_utils_router:
mock_utils_router.PR_IDLE = PR_IDLE
mock_utils_router.PR_PATH_REQUESTED = PR_PATH_REQUESTED
@@ -76,7 +78,9 @@ def mock_app(temp_dir):
app.current_context.message_router = mock_router
with patch.object(
- app, "send_config_to_websocket_clients", return_value=None
+ app,
+ "send_config_to_websocket_clients",
+ return_value=None,
):
yield app
@@ -87,11 +91,11 @@ async def test_lxmf_propagation_config(mock_app):
node_hash_bytes = bytes.fromhex(node_hash_hex)
await mock_app.update_config(
- {"lxmf_preferred_propagation_node_destination_hash": node_hash_hex}
+ {"lxmf_preferred_propagation_node_destination_hash": node_hash_hex},
)
mock_app.current_context.message_router.set_outbound_propagation_node.assert_called_with(
- node_hash_bytes
+ node_hash_bytes,
)
assert (
mock_app.config.lxmf_preferred_propagation_node_destination_hash.get()
@@ -159,7 +163,7 @@ async def test_send_failed_via_prop_node(mock_app):
@pytest.mark.asyncio
async def test_auto_sync_interval_config(mock_app):
await mock_app.update_config(
- {"lxmf_preferred_propagation_node_auto_sync_interval_seconds": 3600}
+ {"lxmf_preferred_propagation_node_auto_sync_interval_seconds": 3600},
)
assert (
mock_app.config.lxmf_preferred_propagation_node_auto_sync_interval_seconds.get()
@@ -198,17 +202,17 @@ async def test_user_provided_node_hash(mock_app):
# Set this node as preferred
await mock_app.update_config(
- {"lxmf_preferred_propagation_node_destination_hash": node_hash_hex}
+ {"lxmf_preferred_propagation_node_destination_hash": node_hash_hex},
)
# Check if the router was updated with the correct bytes
mock_app.current_context.message_router.set_outbound_propagation_node.assert_called_with(
- bytes.fromhex(node_hash_hex)
+ bytes.fromhex(node_hash_hex),
)
# Trigger a sync request
mock_app.current_context.message_router.get_outbound_propagation_node.return_value = bytes.fromhex(
- node_hash_hex
+ node_hash_hex,
)
sync_handler = next(
r.handler
@@ -219,5 +223,5 @@ async def test_user_provided_node_hash(mock_app):
# Verify the router was told to sync for our identity
mock_app.current_context.message_router.request_messages_from_propagation_node.assert_called_with(
- mock_app.current_context.identity
+ mock_app.current_context.identity,
)
diff --git a/tests/backend/test_lxmf_sync.py b/tests/backend/test_lxmf_sync.py
index 8fa09eb2..d4494753 100644
--- a/tests/backend/test_lxmf_sync.py
+++ b/tests/backend/test_lxmf_sync.py
@@ -1,11 +1,13 @@
+import json
import shutil
import tempfile
-import pytest
-import json
from unittest.mock import MagicMock, patch
-from meshchatx.meshchat import ReticulumMeshChat
-import RNS
+
import LXMF
+import pytest
+import RNS
+
+from meshchatx.meshchat import ReticulumMeshChat
# Store original constants
PR_IDLE = LXMF.LXMRouter.PR_IDLE
@@ -48,7 +50,7 @@ def mock_app(temp_dir):
mock_rns_inst.transport_enabled.return_value = False
with patch(
- "meshchatx.src.backend.meshchat_utils.LXMRouter"
+ "meshchatx.src.backend.meshchat_utils.LXMRouter",
) as mock_utils_router:
mock_utils_router.PR_IDLE = PR_IDLE
mock_utils_router.PR_COMPLETE = PR_COMPLETE
@@ -62,7 +64,9 @@ def mock_app(temp_dir):
app.current_context.message_router = mock_router
with patch.object(
- app, "send_config_to_websocket_clients", return_value=None
+ app,
+ "send_config_to_websocket_clients",
+ return_value=None,
):
yield app
@@ -117,13 +121,13 @@ async def test_specific_node_hash_validation(mock_app):
with patch.object(mock_app, "send_config_to_websocket_clients", return_value=None):
# Set the preferred propagation node
await mock_app.update_config(
- {"lxmf_preferred_propagation_node_destination_hash": node_hash_hex}
+ {"lxmf_preferred_propagation_node_destination_hash": node_hash_hex},
)
# Verify it was set on the router correctly as 16 bytes
expected_bytes = bytes.fromhex(node_hash_hex)
mock_app.current_context.message_router.set_outbound_propagation_node.assert_called_with(
- expected_bytes
+ expected_bytes,
)
# Trigger sync
diff --git a/tests/backend/test_lxmf_utils_extended.py b/tests/backend/test_lxmf_utils_extended.py
index 3341eb72..b3e371aa 100644
--- a/tests/backend/test_lxmf_utils_extended.py
+++ b/tests/backend/test_lxmf_utils_extended.py
@@ -3,10 +3,11 @@ import json
from unittest.mock import MagicMock
import LXMF
+
from meshchatx.src.backend.lxmf_utils import (
+ convert_db_lxmf_message_to_dict,
convert_lxmf_message_to_dict,
convert_lxmf_state_to_string,
- convert_db_lxmf_message_to_dict,
)
@@ -129,9 +130,9 @@ def test_convert_db_lxmf_message_to_dict():
{
"file_name": "f.txt",
"file_bytes": base64.b64encode(b"file").decode(),
- }
+ },
],
- }
+ },
),
"timestamp": 1234567890,
"rssi": -60,
diff --git a/tests/backend/test_map_manager_extended.py b/tests/backend/test_map_manager_extended.py
index d0ac446a..52dbb94e 100644
--- a/tests/backend/test_map_manager_extended.py
+++ b/tests/backend/test_map_manager_extended.py
@@ -1,10 +1,11 @@
import os
import shutil
-import tempfile
import sqlite3
+import tempfile
from unittest.mock import MagicMock, patch
import pytest
+
from meshchatx.src.backend.map_manager import MapManager
@@ -83,11 +84,12 @@ def test_get_tile(mock_config, temp_dir):
conn = sqlite3.connect(db_path)
conn.execute(
- "CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob)"
+ "CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob)",
)
# Zoom 0, Tile 0,0. TMS y for 0/0/0 is (1<<0)-1-0 = 0
conn.execute(
- "INSERT INTO tiles VALUES (0, 0, 0, ?)", (sqlite3.Binary(b"tile_data"),)
+ "INSERT INTO tiles VALUES (0, 0, 0, ?)",
+ (sqlite3.Binary(b"tile_data"),),
)
conn.commit()
conn.close()
diff --git a/tests/backend/test_markdown_renderer.py b/tests/backend/test_markdown_renderer.py
index abf89d96..4f69aaee 100644
--- a/tests/backend/test_markdown_renderer.py
+++ b/tests/backend/test_markdown_renderer.py
@@ -1,4 +1,5 @@
import unittest
+
from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
@@ -24,7 +25,7 @@ class TestMarkdownRenderer(unittest.TestCase):
# Check for escaped characters
self.assertTrue(
"print('hello')" in rendered
- or "print('hello')" in rendered
+ or "print('hello')" in rendered,
)
def test_lists(self):
diff --git a/tests/backend/test_memory_profiling.py b/tests/backend/test_memory_profiling.py
index 30930901..649a0c74 100644
--- a/tests/backend/test_memory_profiling.py
+++ b/tests/backend/test_memory_profiling.py
@@ -1,8 +1,9 @@
-import unittest
import os
+import secrets
import shutil
import tempfile
-import secrets
+import unittest
+
from meshchatx.src.backend.database import Database
from meshchatx.src.backend.identity_manager import IdentityManager
from tests.backend.benchmarking_utils import MemoryTracker
@@ -54,7 +55,9 @@ class TestMemoryProfiling(unittest.TestCase):
# 10k messages * 512 bytes is ~5MB of raw content.
# SQLite should handle this efficiently.
self.assertLess(
- tracker.mem_delta, 20.0, "Excessive memory growth during DB insertion"
+ tracker.mem_delta,
+ 20.0,
+ "Excessive memory growth during DB insertion",
)
def test_identity_manager_memory(self):
@@ -70,7 +73,9 @@ class TestMemoryProfiling(unittest.TestCase):
self.assertEqual(len(identities), 50)
self.assertLess(
- tracker.mem_delta, 10.0, "Identity management consumed too much memory"
+ tracker.mem_delta,
+ 10.0,
+ "Identity management consumed too much memory",
)
def test_large_message_processing(self):
@@ -124,7 +129,9 @@ class TestMemoryProfiling(unittest.TestCase):
self.db.announces.upsert_announce(data)
self.assertLess(
- tracker.mem_delta, 15.0, "Announce updates causing memory bloat"
+ tracker.mem_delta,
+ 15.0,
+ "Announce updates causing memory bloat",
)
diff --git a/tests/backend/test_meshchat_utils.py b/tests/backend/test_meshchat_utils.py
index c68bc403..11884bc4 100644
--- a/tests/backend/test_meshchat_utils.py
+++ b/tests/backend/test_meshchat_utils.py
@@ -1,8 +1,8 @@
import os
import shutil
import tempfile
-from unittest.mock import MagicMock, patch
from contextlib import ExitStack
+from unittest.mock import MagicMock, patch
import pytest
import RNS
@@ -31,36 +31,36 @@ def mock_app(temp_dir):
with ExitStack() as stack:
stack.enter_context(patch("meshchatx.src.backend.identity_context.Database"))
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.ConfigManager")
+ patch("meshchatx.src.backend.identity_context.ConfigManager"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.MessageHandler")
+ patch("meshchatx.src.backend.identity_context.MessageHandler"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.AnnounceManager")
+ patch("meshchatx.src.backend.identity_context.AnnounceManager"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.ArchiverManager")
+ patch("meshchatx.src.backend.identity_context.ArchiverManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.TelephoneManager")
+ patch("meshchatx.src.backend.identity_context.TelephoneManager"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.VoicemailManager")
+ patch("meshchatx.src.backend.identity_context.VoicemailManager"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.RingtoneManager")
+ patch("meshchatx.src.backend.identity_context.RingtoneManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.RNStatusHandler")
+ patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.RNProbeHandler")
+ patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.TranslatorHandler")
+ patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
)
stack.enter_context(patch("LXMF.LXMRouter"))
stack.enter_context(patch("RNS.Identity", MockIdentityClass))
@@ -72,34 +72,34 @@ def mock_app(temp_dir):
ReticulumMeshChat,
"announce_loop",
new=MagicMock(return_value=None),
- )
+ ),
)
stack.enter_context(
patch.object(
ReticulumMeshChat,
"announce_sync_propagation_nodes",
new=MagicMock(return_value=None),
- )
+ ),
)
stack.enter_context(
patch.object(
ReticulumMeshChat,
"crawler_loop",
new=MagicMock(return_value=None),
- )
+ ),
)
mock_id = MockIdentityClass()
mock_id.get_private_key = MagicMock(return_value=b"test_private_key")
stack.enter_context(
- patch.object(MockIdentityClass, "from_file", return_value=mock_id)
+ patch.object(MockIdentityClass, "from_file", return_value=mock_id),
)
stack.enter_context(
- patch.object(MockIdentityClass, "recall", return_value=mock_id)
+ patch.object(MockIdentityClass, "recall", return_value=mock_id),
)
stack.enter_context(
- patch.object(MockIdentityClass, "from_bytes", return_value=mock_id)
+ patch.object(MockIdentityClass, "from_bytes", return_value=mock_id),
)
app = ReticulumMeshChat(
diff --git a/tests/backend/test_message_handler.py b/tests/backend/test_message_handler.py
index 17a0a0f6..7b8c538d 100644
--- a/tests/backend/test_message_handler.py
+++ b/tests/backend/test_message_handler.py
@@ -1,5 +1,6 @@
import unittest
from unittest.mock import MagicMock
+
from meshchatx.src.backend.message_handler import MessageHandler
diff --git a/tests/backend/test_nomadnet_downloader.py b/tests/backend/test_nomadnet_downloader.py
index 58716618..1e589187 100644
--- a/tests/backend/test_nomadnet_downloader.py
+++ b/tests/backend/test_nomadnet_downloader.py
@@ -1,5 +1,6 @@
import unittest
from unittest.mock import MagicMock
+
from meshchatx.src.backend.nomadnet_downloader import NomadnetDownloader
diff --git a/tests/backend/test_notifications.py b/tests/backend/test_notifications.py
index e31012f0..3b1ba6d8 100644
--- a/tests/backend/test_notifications.py
+++ b/tests/backend/test_notifications.py
@@ -1,7 +1,7 @@
import os
import time
-from unittest.mock import MagicMock, patch
from contextlib import ExitStack
+from unittest.mock import MagicMock, patch
import pytest
import RNS
@@ -49,33 +49,33 @@ def mock_app(db, tmp_path):
stack.enter_context(patch("RNS.Transport"))
stack.enter_context(patch("LXMF.LXMRouter"))
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.TelephoneManager")
+ patch("meshchatx.src.backend.identity_context.TelephoneManager"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.VoicemailManager")
+ patch("meshchatx.src.backend.identity_context.VoicemailManager"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.RingtoneManager")
+ patch("meshchatx.src.backend.identity_context.RingtoneManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.RNStatusHandler")
+ patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.RNProbeHandler")
+ patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.TranslatorHandler")
+ patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.ArchiverManager")
+ patch("meshchatx.src.backend.identity_context.ArchiverManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.MessageHandler")
+ patch("meshchatx.src.backend.identity_context.MessageHandler"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.AnnounceManager")
+ patch("meshchatx.src.backend.identity_context.AnnounceManager"),
)
stack.enter_context(patch("threading.Thread"))
@@ -83,44 +83,52 @@ def mock_app(db, tmp_path):
mock_id.get_private_key = MagicMock(return_value=b"test_private_key")
stack.enter_context(
- patch.object(MockIdentityClass, "from_file", return_value=mock_id)
+ patch.object(MockIdentityClass, "from_file", return_value=mock_id),
)
stack.enter_context(
- patch.object(MockIdentityClass, "recall", return_value=mock_id)
+ patch.object(MockIdentityClass, "recall", return_value=mock_id),
)
stack.enter_context(
- patch.object(MockIdentityClass, "from_bytes", return_value=mock_id)
+ patch.object(MockIdentityClass, "from_bytes", return_value=mock_id),
)
# Patch background threads and other heavy init
stack.enter_context(
patch.object(
- ReticulumMeshChat, "announce_loop", new=MagicMock(return_value=None)
- )
+ ReticulumMeshChat,
+ "announce_loop",
+ new=MagicMock(return_value=None),
+ ),
)
stack.enter_context(
patch.object(
ReticulumMeshChat,
"announce_sync_propagation_nodes",
new=MagicMock(return_value=None),
- )
+ ),
)
stack.enter_context(
patch.object(
- ReticulumMeshChat, "crawler_loop", new=MagicMock(return_value=None)
- )
+ ReticulumMeshChat,
+ "crawler_loop",
+ new=MagicMock(return_value=None),
+ ),
)
stack.enter_context(
patch.object(
- ReticulumMeshChat, "auto_backup_loop", new=MagicMock(return_value=None)
- )
+ ReticulumMeshChat,
+ "auto_backup_loop",
+ new=MagicMock(return_value=None),
+ ),
)
# Prevent JSON serialization issues with MagicMocks
stack.enter_context(
patch.object(
- ReticulumMeshChat, "send_config_to_websocket_clients", return_value=None
- )
+ ReticulumMeshChat,
+ "send_config_to_websocket_clients",
+ return_value=None,
+ ),
)
app = ReticulumMeshChat(
@@ -262,7 +270,10 @@ async def test_notifications_api(mock_app):
# Let's test a spike of notifications
for i in range(100):
mock_app.database.misc.add_notification(
- f"type{i}", f"hash{i}", f"title{i}", f"content{i}"
+ f"type{i}",
+ f"hash{i}",
+ f"title{i}",
+ f"content{i}",
)
notifications = mock_app.database.misc.get_notifications(limit=50)
@@ -295,7 +306,10 @@ def test_voicemail_notification_fuzzing(mock_app, remote_hash, remote_name, dura
call_was_established=st.booleans(),
)
def test_missed_call_notification_fuzzing(
- mock_app, remote_hash, status_code, call_was_established
+ mock_app,
+ remote_hash,
+ status_code,
+ call_was_established,
):
"""Fuzz missed call notification triggering."""
mock_app.database.misc.provider.execute("DELETE FROM notifications")
diff --git a/tests/backend/test_performance_bottlenecks.py b/tests/backend/test_performance_bottlenecks.py
index 9655484e..89f10814 100644
--- a/tests/backend/test_performance_bottlenecks.py
+++ b/tests/backend/test_performance_bottlenecks.py
@@ -1,12 +1,13 @@
-import unittest
import os
+import secrets
import shutil
import tempfile
import time
-import secrets
+import unittest
from unittest.mock import MagicMock
-from meshchatx.src.backend.database import Database
+
from meshchatx.src.backend.announce_manager import AnnounceManager
+from meshchatx.src.backend.database import Database
class TestPerformanceBottlenecks(unittest.TestCase):
@@ -60,7 +61,9 @@ class TestPerformanceBottlenecks(unittest.TestCase):
for offset in offsets:
start = time.time()
msgs = self.db.messages.get_conversation_messages(
- peer_hash, limit=limit, offset=offset
+ peer_hash,
+ limit=limit,
+ offset=offset,
)
duration = (time.time() - start) * 1000
print(f"Fetch {limit} messages at offset {offset}: {duration:.2f}ms")
@@ -103,7 +106,7 @@ class TestPerformanceBottlenecks(unittest.TestCase):
duration_total = time.time() - start_total
avg_duration = (duration_total / num_announces) * 1000
print(
- f"Processed {num_announces} announces in {duration_total:.2f}s (Avg: {avg_duration:.2f}ms/announce)"
+ f"Processed {num_announces} announces in {duration_total:.2f}s (Avg: {avg_duration:.2f}ms/announce)",
)
self.assertLess(avg_duration, 20, "Announce processing is too slow!")
@@ -129,7 +132,9 @@ class TestPerformanceBottlenecks(unittest.TestCase):
# Benchmark filtered search with pagination
start = time.time()
results = self.announce_manager.get_filtered_announces(
- aspect="lxmf.delivery", limit=50, offset=1000
+ aspect="lxmf.delivery",
+ limit=50,
+ offset=1000,
)
duration = (time.time() - start) * 1000
print(f"Filtered announce pagination (offset 1000): {duration:.2f}ms")
@@ -164,7 +169,7 @@ class TestPerformanceBottlenecks(unittest.TestCase):
]
print(
- f"\nRunning {num_threads} threads inserting {announces_per_thread} announces each..."
+ f"\nRunning {num_threads} threads inserting {announces_per_thread} announces each...",
)
start = time.time()
for t in threads:
@@ -174,7 +179,7 @@ class TestPerformanceBottlenecks(unittest.TestCase):
duration = time.time() - start
print(
- f"Concurrent insertion took {duration:.2f}s for {num_threads * announces_per_thread} announces"
+ f"Concurrent insertion took {duration:.2f}s for {num_threads * announces_per_thread} announces",
)
self.assertLess(duration, 10.0, "Concurrent announce insertion is too slow!")
diff --git a/tests/backend/test_propagation_nodes_robustness.py b/tests/backend/test_propagation_nodes_robustness.py
index b34803e6..ab0a56c9 100644
--- a/tests/backend/test_propagation_nodes_robustness.py
+++ b/tests/backend/test_propagation_nodes_robustness.py
@@ -1,11 +1,13 @@
+import json
import shutil
import tempfile
-import pytest
-import json
from unittest.mock import MagicMock, patch
-from meshchatx.meshchat import ReticulumMeshChat
+
+import pytest
import RNS
+from meshchatx.meshchat import ReticulumMeshChat
+
@pytest.fixture
def temp_dir():
diff --git a/tests/backend/test_rncp_handler_extended.py b/tests/backend/test_rncp_handler_extended.py
index 2fb1afe3..6405150a 100644
--- a/tests/backend/test_rncp_handler_extended.py
+++ b/tests/backend/test_rncp_handler_extended.py
@@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch
import pytest
import RNS
+
from meshchatx.src.backend.rncp_handler import RNCPHandler
@@ -40,7 +41,9 @@ def mock_rns():
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
patch.object(
- MockIdentityClass, "from_bytes", return_value=mock_id_instance
+ MockIdentityClass,
+ "from_bytes",
+ return_value=mock_id_instance,
),
):
mock_dest_instance = MagicMock()
@@ -85,7 +88,9 @@ def test_setup_receive_destination(mock_rns, temp_dir):
mock_rns["Reticulum"].identitypath = temp_dir
_ = handler.setup_receive_destination(
- allowed_hashes=["abc123def456"], fetch_allowed=True, fetch_jail=temp_dir
+ allowed_hashes=["abc123def456"],
+ fetch_allowed=True,
+ fetch_jail=temp_dir,
)
assert handler.receive_destination is not None
diff --git a/tests/backend/test_rnpath_logic.py b/tests/backend/test_rnpath_logic.py
index 33e6175a..c3ba289d 100644
--- a/tests/backend/test_rnpath_logic.py
+++ b/tests/backend/test_rnpath_logic.py
@@ -1,9 +1,11 @@
-import pytest
import json
-from unittest.mock import MagicMock, patch, AsyncMock
-from meshchatx.meshchat import ReticulumMeshChat
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
import RNS
+from meshchatx.meshchat import ReticulumMeshChat
+
@pytest.fixture
def temp_dir(tmp_path):
diff --git a/tests/backend/test_rns_lifecycle.py b/tests/backend/test_rns_lifecycle.py
index 5232c544..4b9e3c67 100644
--- a/tests/backend/test_rns_lifecycle.py
+++ b/tests/backend/test_rns_lifecycle.py
@@ -45,7 +45,9 @@ def mock_rns():
new=MagicMock(return_value=None),
),
patch.object(
- ReticulumMeshChat, "send_config_to_websocket_clients", return_value=None
+ ReticulumMeshChat,
+ "send_config_to_websocket_clients",
+ return_value=None,
),
):
# Setup mock instance
@@ -57,10 +59,14 @@ def mock_rns():
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
patch.object(
- MockIdentityClass, "from_bytes", return_value=mock_id_instance
+ MockIdentityClass,
+ "from_bytes",
+ return_value=mock_id_instance,
),
patch.object(
- MockIdentityClass, "full_hash", return_value=b"full_hash_bytes"
+ MockIdentityClass,
+ "full_hash",
+ return_value=b"full_hash_bytes",
),
):
# Setup mock transport
@@ -263,7 +269,7 @@ async def test_hotswap_identity(mock_rns, temp_dir):
with (
patch("meshchatx.src.backend.identity_context.Database"),
patch(
- "meshchatx.src.backend.identity_context.ConfigManager"
+ "meshchatx.src.backend.identity_context.ConfigManager",
) as mock_config_class,
patch("meshchatx.src.backend.identity_context.MessageHandler"),
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
diff --git a/tests/backend/test_rnstatus_blackhole.py b/tests/backend/test_rnstatus_blackhole.py
index 2911616b..48a6efce 100644
--- a/tests/backend/test_rnstatus_blackhole.py
+++ b/tests/backend/test_rnstatus_blackhole.py
@@ -1,8 +1,10 @@
-import pytest
from unittest.mock import MagicMock, patch
-from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
+
+import pytest
import RNS
+from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
+
@pytest.fixture
def mock_reticulum_instance():
@@ -50,7 +52,7 @@ def test_blackhole_status_missing_api(mock_reticulum_instance):
# But we can patch the RNS object inside rnstatus_handler module.
with patch(
- "meshchatx.src.backend.rnstatus_handler.RNS.Reticulum"
+ "meshchatx.src.backend.rnstatus_handler.RNS.Reticulum",
) as mock_rns_class:
del mock_rns_class.publish_blackhole_enabled
diff --git a/tests/backend/test_security_fuzzing.py b/tests/backend/test_security_fuzzing.py
index 9125dc02..b3d46308 100644
--- a/tests/backend/test_security_fuzzing.py
+++ b/tests/backend/test_security_fuzzing.py
@@ -1,6 +1,6 @@
+import base64
import os
import time
-import base64
from contextlib import ExitStack
from unittest.mock import MagicMock, patch
@@ -27,39 +27,39 @@ def mock_app():
# Mock core dependencies that interact with the system/network
stack.enter_context(patch("meshchatx.src.backend.identity_context.Database"))
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.ConfigManager")
+ patch("meshchatx.src.backend.identity_context.ConfigManager"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.MessageHandler")
+ patch("meshchatx.src.backend.identity_context.MessageHandler"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.AnnounceManager")
+ patch("meshchatx.src.backend.identity_context.AnnounceManager"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.ArchiverManager")
+ patch("meshchatx.src.backend.identity_context.ArchiverManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.TelephoneManager")
+ patch("meshchatx.src.backend.identity_context.TelephoneManager"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.VoicemailManager")
+ patch("meshchatx.src.backend.identity_context.VoicemailManager"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.RingtoneManager")
+ patch("meshchatx.src.backend.identity_context.RingtoneManager"),
)
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.RNStatusHandler")
+ patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.RNProbeHandler")
+ patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.TranslatorHandler")
+ patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
)
stack.enter_context(
- patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager")
+ patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
)
mock_async_utils = stack.enter_context(patch("meshchatx.meshchat.AsyncUtils"))
@@ -72,36 +72,40 @@ def mock_app():
# Stop background loops
stack.enter_context(
- patch.object(ReticulumMeshChat, "announce_loop", return_value=None)
+ patch.object(ReticulumMeshChat, "announce_loop", return_value=None),
)
stack.enter_context(
patch.object(
- ReticulumMeshChat, "announce_sync_propagation_nodes", return_value=None
- )
+ ReticulumMeshChat,
+ "announce_sync_propagation_nodes",
+ return_value=None,
+ ),
)
stack.enter_context(
- patch.object(ReticulumMeshChat, "crawler_loop", return_value=None)
+ patch.object(ReticulumMeshChat, "crawler_loop", return_value=None),
)
stack.enter_context(
- patch.object(ReticulumMeshChat, "auto_backup_loop", return_value=None)
+ patch.object(ReticulumMeshChat, "auto_backup_loop", return_value=None),
)
stack.enter_context(
patch.object(
- ReticulumMeshChat, "send_config_to_websocket_clients", return_value=None
- )
+ ReticulumMeshChat,
+ "send_config_to_websocket_clients",
+ return_value=None,
+ ),
)
mock_id = MockIdentityClass()
mock_id.get_private_key = MagicMock(return_value=b"test_private_key")
stack.enter_context(
- patch.object(MockIdentityClass, "from_file", return_value=mock_id)
+ patch.object(MockIdentityClass, "from_file", return_value=mock_id),
)
stack.enter_context(
- patch.object(MockIdentityClass, "recall", return_value=mock_id)
+ patch.object(MockIdentityClass, "recall", return_value=mock_id),
)
stack.enter_context(
- patch.object(MockIdentityClass, "from_bytes", return_value=mock_id)
+ patch.object(MockIdentityClass, "from_bytes", return_value=mock_id),
)
def mock_run_async(coro):
@@ -117,10 +121,10 @@ def mock_app():
return MagicMock()
mock_telephone_manager = stack.enter_context(
- patch("meshchatx.src.backend.identity_context.TelephoneManager")
+ patch("meshchatx.src.backend.identity_context.TelephoneManager"),
)
mock_telephone_manager.return_value.initiate = MagicMock(
- side_effect=mock_initiate
+ side_effect=mock_initiate,
)
app = ReticulumMeshChat(
@@ -1015,7 +1019,11 @@ def test_telemetry_unpack_location_fuzzing(mock_app, packed_location):
bearing=st.one_of(st.floats(allow_nan=True, allow_infinity=True), st.integers()),
accuracy=st.one_of(st.floats(allow_nan=True, allow_infinity=True), st.integers()),
last_update=st.one_of(
- st.integers(), st.floats(), st.text(), st.binary(), st.none()
+ st.integers(),
+ st.floats(),
+ st.text(),
+ st.binary(),
+ st.none(),
),
)
def test_telemetry_pack_location_fuzzing(
@@ -1052,12 +1060,15 @@ def test_telemetry_pack_location_fuzzing(
st.none(),
),
data=st.one_of(
- st.text(), st.binary(), st.dictionaries(keys=st.text(), values=st.text())
+ st.text(),
+ st.binary(),
+ st.dictionaries(keys=st.text(), values=st.text()),
),
received_from=st.one_of(st.text(), st.binary(), st.none()),
physical_link=st.one_of(
st.dictionaries(
- keys=st.text(), values=st.one_of(st.integers(), st.floats(), st.text())
+ keys=st.text(),
+ values=st.one_of(st.integers(), st.floats(), st.text()),
),
st.text(),
st.binary(),
@@ -1456,7 +1467,9 @@ def test_lxst_audio_frame_handling_fuzzing(mock_app, audio_frame):
caller_identity_hash=st.binary(min_size=0, max_size=100),
)
def test_lxst_call_state_transitions_fuzzing(
- mock_app, call_status, caller_identity_hash
+ mock_app,
+ call_status,
+ caller_identity_hash,
):
"""Fuzz LXST call state transitions with invalid states."""
try:
@@ -1490,7 +1503,7 @@ def test_lxst_call_state_transitions_fuzzing(
"2400",
"3200",
"invalid",
- ]
+ ],
),
)
def test_codec2_decode_fuzzing(mock_app, codec2_data, codec_mode):
@@ -1577,7 +1590,8 @@ def test_lxst_profile_switching_fuzzing(mock_app, profile_id):
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(
destination_hash=st.one_of(
- st.binary(min_size=0, max_size=100), st.text(min_size=0, max_size=100)
+ st.binary(min_size=0, max_size=100),
+ st.text(min_size=0, max_size=100),
),
timeout=st.one_of(
st.integers(min_value=-100, max_value=1000),
@@ -1612,7 +1626,8 @@ def test_lxst_call_initiation_fuzzing(mock_app, destination_hash, timeout):
loop.run_until_complete(
mock_app.telephone_manager.initiate(
- dest_hash_bytes, timeout_seconds=timeout_int
+ dest_hash_bytes,
+ timeout_seconds=timeout_int,
),
)
finally:
@@ -1716,16 +1731,21 @@ def test_lxmf_message_unpacking_fuzzing(mock_app, lxmf_message_data):
pipeline_config=st.dictionaries(
keys=st.text(),
values=st.one_of(
- st.text(), st.binary(), st.integers(), st.floats(), st.booleans(), st.none()
+ st.text(),
+ st.binary(),
+ st.integers(),
+ st.floats(),
+ st.booleans(),
+ st.none(),
),
),
)
def test_lxst_pipeline_config_fuzzing(mock_app, pipeline_config):
"""Fuzz LXST Pipeline configuration."""
- from LXST.Pipeline import Pipeline
from LXST.Codecs import Null
- from LXST.Sources import Source
+ from LXST.Pipeline import Pipeline
from LXST.Sinks import Sink
+ from LXST.Sources import Source
class DummySource(Source):
pass
@@ -1748,7 +1768,8 @@ def test_lxst_pipeline_config_fuzzing(mock_app, pipeline_config):
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(
sink_data=st.one_of(
- st.binary(min_size=0, max_size=10000), st.text(min_size=0, max_size=1000)
+ st.binary(min_size=0, max_size=10000),
+ st.text(min_size=0, max_size=1000),
),
)
def test_lxst_sink_handling_fuzzing(mock_app, sink_data):
@@ -1784,7 +1805,8 @@ def test_telemetry_packing_invariants_regression():
}
packed = Telemeter.pack(
- time_utc=original_data["time"]["utc"], location=original_data["location"]
+ time_utc=original_data["time"]["utc"],
+ location=original_data["location"],
)
unpacked = Telemeter.from_packed(packed)
diff --git a/tests/backend/test_startup.py b/tests/backend/test_startup.py
index e460f1c8..a99c6252 100644
--- a/tests/backend/test_startup.py
+++ b/tests/backend/test_startup.py
@@ -34,12 +34,16 @@ def mock_rns():
patch("meshchatx.meshchat.get_file_path", return_value="/tmp/mock_path"),
patch.object(ReticulumMeshChat, "announce_loop", return_value=None),
patch.object(
- ReticulumMeshChat, "announce_sync_propagation_nodes", return_value=None
+ ReticulumMeshChat,
+ "announce_sync_propagation_nodes",
+ return_value=None,
),
patch.object(ReticulumMeshChat, "crawler_loop", return_value=None),
patch.object(ReticulumMeshChat, "auto_backup_loop", return_value=None),
patch.object(
- ReticulumMeshChat, "send_config_to_websocket_clients", return_value=None
+ ReticulumMeshChat,
+ "send_config_to_websocket_clients",
+ return_value=None,
),
):
# Setup mock instance
@@ -51,7 +55,9 @@ def mock_rns():
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
patch.object(
- MockIdentityClass, "from_bytes", return_value=mock_id_instance
+ MockIdentityClass,
+ "from_bytes",
+ return_value=mock_id_instance,
),
):
# Setup mock transport
@@ -83,7 +89,7 @@ def test_reticulum_meshchat_init(mock_rns, temp_dir):
with (
patch("meshchatx.src.backend.identity_context.Database") as mock_db_class,
patch(
- "meshchatx.src.backend.identity_context.ConfigManager"
+ "meshchatx.src.backend.identity_context.ConfigManager",
) as mock_config_class,
patch("meshchatx.src.backend.identity_context.MessageHandler"),
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
@@ -152,7 +158,7 @@ def test_reticulum_meshchat_init_with_auth(mock_rns, temp_dir):
with (
patch("meshchatx.src.backend.identity_context.Database"),
patch(
- "meshchatx.src.backend.identity_context.ConfigManager"
+ "meshchatx.src.backend.identity_context.ConfigManager",
) as mock_config_class,
patch("meshchatx.src.backend.identity_context.MessageHandler"),
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
diff --git a/tests/backend/test_startup_advanced.py b/tests/backend/test_startup_advanced.py
index 9facb933..0e692a2a 100644
--- a/tests/backend/test_startup_advanced.py
+++ b/tests/backend/test_startup_advanced.py
@@ -1,11 +1,12 @@
-import shutil
-import tempfile
import base64
import secrets
-from unittest.mock import MagicMock, patch, mock_open
+import shutil
+import tempfile
+from unittest.mock import MagicMock, mock_open, patch
import pytest
import RNS
+
from meshchatx.meshchat import ReticulumMeshChat, main
@@ -43,12 +44,16 @@ def mock_rns():
patch("LXMF.LXMRouter"),
patch.object(ReticulumMeshChat, "announce_loop", return_value=None),
patch.object(
- ReticulumMeshChat, "announce_sync_propagation_nodes", return_value=None
+ ReticulumMeshChat,
+ "announce_sync_propagation_nodes",
+ return_value=None,
),
patch.object(ReticulumMeshChat, "crawler_loop", return_value=None),
patch.object(ReticulumMeshChat, "auto_backup_loop", return_value=None),
patch.object(
- ReticulumMeshChat, "send_config_to_websocket_clients", return_value=None
+ ReticulumMeshChat,
+ "send_config_to_websocket_clients",
+ return_value=None,
),
):
mock_id_instance = MockIdentityClass()
@@ -57,7 +62,9 @@ def mock_rns():
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
patch.object(
- MockIdentityClass, "from_bytes", return_value=mock_id_instance
+ MockIdentityClass,
+ "from_bytes",
+ return_value=mock_id_instance,
),
):
yield {
@@ -73,7 +80,7 @@ def test_run_https_logic(mock_rns, temp_dir):
with (
patch("meshchatx.src.backend.identity_context.Database"),
patch(
- "meshchatx.src.backend.identity_context.ConfigManager"
+ "meshchatx.src.backend.identity_context.ConfigManager",
) as mock_config_class,
patch("meshchatx.meshchat.generate_ssl_certificate") as mock_gen_cert,
patch("ssl.SSLContext") as mock_ssl_context,
@@ -97,7 +104,7 @@ def test_run_https_logic(mock_rns, temp_dir):
mock_config = mock_config_class.return_value
# provide a real-looking secret key
mock_config.auth_session_secret.get.return_value = base64.urlsafe_b64encode(
- secrets.token_bytes(32)
+ secrets.token_bytes(32),
).decode()
mock_config.display_name.get.return_value = "Test"
mock_config.lxmf_propagation_node_stamp_cost.get.return_value = 0
@@ -137,7 +144,7 @@ def test_database_integrity_recovery(mock_rns, temp_dir):
with (
patch("meshchatx.src.backend.identity_context.Database") as mock_db_class,
patch(
- "meshchatx.src.backend.identity_context.ConfigManager"
+ "meshchatx.src.backend.identity_context.ConfigManager",
) as mock_config_class,
patch("meshchatx.src.backend.identity_context.MessageHandler"),
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
@@ -190,7 +197,7 @@ def test_identity_loading_fallback(mock_rns, temp_dir):
with (
patch("meshchatx.src.backend.identity_context.Database"),
patch(
- "meshchatx.src.backend.identity_context.ConfigManager"
+ "meshchatx.src.backend.identity_context.ConfigManager",
) as mock_config_class,
patch("RNS.Identity") as mock_id_class,
patch("os.path.exists", return_value=False), # Pretend files don't exist
@@ -210,7 +217,7 @@ def test_identity_loading_fallback(mock_rns, temp_dir):
# Mock sys.argv to use default behavior (random generation)
with patch("sys.argv", ["meshchat.py", "--storage-dir", temp_dir]):
with patch(
- "meshchatx.meshchat.ReticulumMeshChat"
+ "meshchatx.meshchat.ReticulumMeshChat",
): # Mock ReticulumMeshChat to avoid full init
with patch("aiohttp.web.run_app"):
main()
@@ -235,25 +242,25 @@ def test_cli_flags_and_envs(mock_rns, temp_dir):
"MESHCHAT_AUTH": "1",
"MESHCHAT_STORAGE_DIR": temp_dir,
}
- with patch.dict("os.environ", env):
- with patch("sys.argv", ["meshchat.py"]):
- main()
+ with patch.dict("os.environ", env), patch("sys.argv", ["meshchat.py"]):
+ main()
- # Verify ReticulumMeshChat was called with values from ENV
- args, kwargs = mock_app_class.call_args
- assert kwargs["auto_recover"] is True
- assert kwargs["auth_enabled"] is True
+ # Verify ReticulumMeshChat was called with values from ENV
+ args, kwargs = mock_app_class.call_args
+ assert kwargs["auto_recover"] is True
+ assert kwargs["auth_enabled"] is True
- # Verify run was called with host/port from ENV
- mock_app_instance = mock_app_class.return_value
- run_args, run_kwargs = mock_app_instance.run.call_args
- assert run_args[0] == "1.2.3.4"
- assert run_args[1] == 9000
+ # Verify run was called with host/port from ENV
+ mock_app_instance = mock_app_class.return_value
+ run_args, run_kwargs = mock_app_instance.run.call_args
+ assert run_args[0] == "1.2.3.4"
+ assert run_args[1] == 9000
# Test CLI Flags (override Envs)
mock_app_class.reset_mock()
- with patch.dict("os.environ", env):
- with patch(
+ with (
+ patch.dict("os.environ", env),
+ patch(
"sys.argv",
[
"meshchat.py",
@@ -265,11 +272,12 @@ def test_cli_flags_and_envs(mock_rns, temp_dir):
"--storage-dir",
temp_dir,
],
- ):
- main()
-
- mock_app_instance = mock_app_class.return_value
- run_args, run_kwargs = mock_app_instance.run.call_args
- assert run_args[0] == "5.6.7.8"
- assert run_args[1] == 7000
- assert run_kwargs["enable_https"] is False
+ ),
+ ):
+ main()
+
+ mock_app_instance = mock_app_class.return_value
+ run_args, run_kwargs = mock_app_instance.run.call_args
+ assert run_args[0] == "5.6.7.8"
+ assert run_args[1] == 7000
+ assert run_kwargs["enable_https"] is False
diff --git a/tests/backend/test_telephone_recorder.py b/tests/backend/test_telephone_recorder.py
index 756a7d8a..cfb1b725 100644
--- a/tests/backend/test_telephone_recorder.py
+++ b/tests/backend/test_telephone_recorder.py
@@ -38,7 +38,9 @@ def temp_storage(tmp_path):
def test_telephone_manager_init(mock_identity, mock_config, temp_storage):
tm = TelephoneManager(
- mock_identity, config_manager=mock_config, storage_dir=temp_storage
+ mock_identity,
+ config_manager=mock_config,
+ storage_dir=temp_storage,
)
assert tm.identity == mock_identity
assert tm.config_manager == mock_config
@@ -48,7 +50,11 @@ def test_telephone_manager_init(mock_identity, mock_config, temp_storage):
@patch("meshchatx.src.backend.telephone_manager.Telephone")
def test_call_recording_lifecycle(
- mock_telephone_class, mock_identity, mock_config, mock_db, temp_storage
+ mock_telephone_class,
+ mock_identity,
+ mock_config,
+ mock_db,
+ temp_storage,
):
# Setup mocks
mock_telephone = mock_telephone_class.return_value
@@ -63,7 +69,10 @@ def test_call_recording_lifecycle(
mock_telephone.transmit_mixer = MagicMock()
tm = TelephoneManager(
- mock_identity, config_manager=mock_config, storage_dir=temp_storage, db=mock_db
+ mock_identity,
+ config_manager=mock_config,
+ storage_dir=temp_storage,
+ db=mock_db,
)
tm.get_name_for_identity_hash = MagicMock(return_value="Remote User")
tm.init_telephone()
@@ -90,7 +99,10 @@ def test_call_recording_lifecycle(
def test_call_recording_disabled(mock_identity, mock_config, mock_db, temp_storage):
mock_config.call_recording_enabled.get.return_value = False
tm = TelephoneManager(
- mock_identity, config_manager=mock_config, storage_dir=temp_storage, db=mock_db
+ mock_identity,
+ config_manager=mock_config,
+ storage_dir=temp_storage,
+ db=mock_db,
)
# Mock telephone and active call
@@ -105,13 +117,15 @@ def test_call_recording_disabled(mock_identity, mock_config, mock_db, temp_stora
def test_audio_profile_persistence(mock_identity, mock_config, temp_storage):
with patch(
- "meshchatx.src.backend.telephone_manager.Telephone"
+ "meshchatx.src.backend.telephone_manager.Telephone",
) as mock_telephone_class:
mock_telephone = mock_telephone_class.return_value
mock_config.telephone_audio_profile_id.get.return_value = 4
tm = TelephoneManager(
- mock_identity, config_manager=mock_config, storage_dir=temp_storage
+ mock_identity,
+ config_manager=mock_config,
+ storage_dir=temp_storage,
)
tm.init_telephone()
@@ -121,7 +135,11 @@ def test_audio_profile_persistence(mock_identity, mock_config, temp_storage):
@patch("meshchatx.src.backend.telephone_manager.Telephone")
def test_call_recording_saves_after_disconnect(
- mock_telephone_class, mock_identity, mock_config, mock_db, temp_storage
+ mock_telephone_class,
+ mock_identity,
+ mock_config,
+ mock_db,
+ temp_storage,
):
# Setup mocks
mock_telephone = mock_telephone_class.return_value
@@ -136,7 +154,10 @@ def test_call_recording_saves_after_disconnect(
mock_telephone.transmit_mixer = MagicMock()
tm = TelephoneManager(
- mock_identity, config_manager=mock_config, storage_dir=temp_storage, db=mock_db
+ mock_identity,
+ config_manager=mock_config,
+ storage_dir=temp_storage,
+ db=mock_db,
)
tm.init_telephone()
@@ -162,11 +183,16 @@ def test_call_recording_saves_after_disconnect(
@patch("meshchatx.src.backend.telephone_manager.Telephone")
def test_manual_mute_overrides(
- mock_telephone_class, mock_identity, mock_config, temp_storage
+ mock_telephone_class,
+ mock_identity,
+ mock_config,
+ temp_storage,
):
mock_telephone = mock_telephone_class.return_value
tm = TelephoneManager(
- mock_identity, config_manager=mock_config, storage_dir=temp_storage
+ mock_identity,
+ config_manager=mock_config,
+ storage_dir=temp_storage,
)
tm.init_telephone()
diff --git a/tests/backend/test_translator_handler.py b/tests/backend/test_translator_handler.py
index 57875deb..66ba659e 100644
--- a/tests/backend/test_translator_handler.py
+++ b/tests/backend/test_translator_handler.py
@@ -1,5 +1,6 @@
import unittest
from unittest.mock import MagicMock, patch
+
from meshchatx.src.backend.translator_handler import TranslatorHandler
diff --git a/tests/backend/test_voicemail_manager_extended.py b/tests/backend/test_voicemail_manager_extended.py
index bb2f0386..5d0ce62b 100644
--- a/tests/backend/test_voicemail_manager_extended.py
+++ b/tests/backend/test_voicemail_manager_extended.py
@@ -4,6 +4,7 @@ import tempfile
from unittest.mock import MagicMock, patch
import pytest
+
from meshchatx.src.backend.voicemail_manager import VoicemailManager
diff --git a/tests/backend/test_websocket_interfaces.py b/tests/backend/test_websocket_interfaces.py
index cba84ba5..2448acea 100644
--- a/tests/backend/test_websocket_interfaces.py
+++ b/tests/backend/test_websocket_interfaces.py
@@ -1,12 +1,13 @@
+import socket
import unittest
from unittest.mock import MagicMock, patch
-import socket
-from meshchatx.src.backend.interfaces.WebsocketServerInterface import (
- WebsocketServerInterface,
-)
+
from meshchatx.src.backend.interfaces.WebsocketClientInterface import (
WebsocketClientInterface,
)
+from meshchatx.src.backend.interfaces.WebsocketServerInterface import (
+ WebsocketServerInterface,
+)
class TestWebsocketInterfaces(unittest.TestCase):
@@ -43,7 +44,7 @@ class TestWebsocketInterfaces(unittest.TestCase):
# We don't want it to actually try connecting in this basic test
with patch(
- "meshchatx.src.backend.interfaces.WebsocketClientInterface.threading.Thread"
+ "meshchatx.src.backend.interfaces.WebsocketClientInterface.threading.Thread",
):
client = WebsocketClientInterface(self.owner, config)
self.assertEqual(client.name, "test_ws_client")
diff --git a/tests/frontend/AboutPage.test.js b/tests/frontend/AboutPage.test.js
index 96315ab6..d444b8de 100644
--- a/tests/frontend/AboutPage.test.js
+++ b/tests/frontend/AboutPage.test.js
@@ -182,13 +182,13 @@ describe("AboutPage.vue", () => {
});
mountAboutPage();
- expect(axiosMock.get).toHaveBeenCalledTimes(4); // info, config, health, snapshots
+ expect(axiosMock.get).toHaveBeenCalledTimes(5); // info, config, health, snapshots, backups
vi.advanceTimersByTime(5000);
- expect(axiosMock.get).toHaveBeenCalledTimes(5);
+ expect(axiosMock.get).toHaveBeenCalledTimes(6); // +1 from updateInterval
vi.advanceTimersByTime(5000);
- expect(axiosMock.get).toHaveBeenCalledTimes(6);
+ expect(axiosMock.get).toHaveBeenCalledTimes(7); // +2 from updateInterval
});
it("handles vacuum database action", async () => {
diff --git a/tests/frontend/AddInterfaceDiscovery.test.js b/tests/frontend/AddInterfaceDiscovery.test.js
new file mode 100644
index 00000000..87b26eff
--- /dev/null
+++ b/tests/frontend/AddInterfaceDiscovery.test.js
@@ -0,0 +1,86 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import AddInterfacePage from "../../meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue";
+
+// mocks
+const mockAxios = {
+ get: vi.fn(),
+ post: vi.fn(),
+};
+window.axios = mockAxios;
+
+vi.mock("../../meshchatx/src/frontend/js/DialogUtils", () => ({
+ default: {
+ alert: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+describe("AddInterfacePage.vue discovery", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockAxios.get.mockResolvedValue({ data: {} });
+ mockAxios.post.mockResolvedValue({ data: { message: "ok" } });
+ });
+
+ it("adds discovery fields when interface is discoverable", async () => {
+ const wrapper = mount(AddInterfacePage, {
+ global: {
+ mocks: {
+ $route: { query: {} },
+ $router: { push: vi.fn() },
+ $t: (msg) => msg,
+ },
+ stubs: ["RouterLink", "MaterialDesignIcon", "Toggle", "ExpandingSection", "FormLabel", "FormSubLabel"],
+ },
+ });
+
+ // required interface fields
+ wrapper.vm.newInterfaceName = "TestIface";
+ wrapper.vm.newInterfaceType = "TCPClientInterface";
+ wrapper.vm.newInterfaceTargetHost = "example.com";
+ wrapper.vm.newInterfaceTargetPort = "4242";
+
+ // discovery fields
+ wrapper.vm.discovery.discoverable = true;
+ wrapper.vm.discovery.discovery_name = "Region A";
+ wrapper.vm.discovery.announce_interval = 720;
+ wrapper.vm.discovery.reachable_on = "/usr/local/bin/ip.sh";
+ wrapper.vm.discovery.discovery_stamp_value = 22;
+ wrapper.vm.discovery.discovery_encrypt = true;
+ wrapper.vm.discovery.publish_ifac = true;
+ wrapper.vm.discovery.latitude = 1.23;
+ wrapper.vm.discovery.longitude = 4.56;
+ wrapper.vm.discovery.height = 7;
+ wrapper.vm.discovery.discovery_frequency = 915000000;
+ wrapper.vm.discovery.discovery_bandwidth = 125000;
+ wrapper.vm.discovery.discovery_modulation = "LoRa";
+
+ await wrapper.vm.addInterface();
+
+ expect(mockAxios.post).toHaveBeenCalledWith(
+ "/api/v1/reticulum/interfaces/add",
+ expect.objectContaining({
+ discoverable: "yes",
+ discovery_name: "Region A",
+ announce_interval: 720,
+ reachable_on: "/usr/local/bin/ip.sh",
+ discovery_stamp_value: 22,
+ discovery_encrypt: true,
+ publish_ifac: true,
+ latitude: 1.23,
+ longitude: 4.56,
+ height: 7,
+ discovery_frequency: 915000000,
+ discovery_bandwidth: 125000,
+ discovery_modulation: "LoRa",
+ })
+ );
+ });
+});
diff --git a/tests/frontend/InterfacesPage.test.js b/tests/frontend/InterfacesPage.test.js
index 4a50fdcb..b78390b6 100644
--- a/tests/frontend/InterfacesPage.test.js
+++ b/tests/frontend/InterfacesPage.test.js
@@ -7,6 +7,7 @@ import GlobalState from "../../meshchatx/src/frontend/js/GlobalState";
const mockAxios = {
get: vi.fn(),
post: vi.fn(),
+ patch: vi.fn(),
};
window.axios = mockAxios;
@@ -110,4 +111,85 @@ describe("InterfacesPage.vue", () => {
expect(wrapper.vm.modifiedInterfaceNames.size).toBe(0);
expect(mockAxios.post).toHaveBeenCalledWith("/api/v1/reticulum/reload");
});
+
+ it("loads and saves discovery config", async () => {
+ mockAxios.get.mockImplementation((url) => {
+ if (url === "/api/v1/reticulum/interfaces") {
+ return Promise.resolve({ data: { interfaces: [] } });
+ }
+ if (url === "/api/v1/app/info") {
+ return Promise.resolve({ data: { app_info: { is_reticulum_running: true } } });
+ }
+ if (url === "/api/v1/reticulum/discovery") {
+ return Promise.resolve({
+ data: {
+ discovery: {
+ discover_interfaces: "true",
+ interface_discovery_sources: "abc",
+ required_discovery_value: "16",
+ autoconnect_discovered_interfaces: "3",
+ network_identity: "/tmp/netid",
+ },
+ },
+ });
+ }
+ return Promise.reject();
+ });
+
+ mockAxios.patch.mockResolvedValue({
+ data: {
+ discovery: {
+ discover_interfaces: false,
+ interface_discovery_sources: null,
+ required_discovery_value: 18,
+ autoconnect_discovered_interfaces: 5,
+ network_identity: "/tmp/new",
+ },
+ },
+ });
+
+ const wrapper = mount(InterfacesPage, {
+ global: {
+ mocks: {
+ $route: mockRoute,
+ $router: mockRouter,
+ $t: (msg) => msg,
+ },
+ stubs: [
+ "RouterLink",
+ "MaterialDesignIcon",
+ "IconButton",
+ "Interface",
+ "ImportInterfacesModal",
+ "Toggle",
+ ],
+ },
+ });
+
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.discoveryConfig.discover_interfaces).toBe(true);
+ expect(wrapper.vm.discoveryConfig.interface_discovery_sources).toBe("abc");
+ expect(wrapper.vm.discoveryConfig.required_discovery_value).toBe(16);
+ expect(wrapper.vm.discoveryConfig.autoconnect_discovered_interfaces).toBe(3);
+ expect(wrapper.vm.discoveryConfig.network_identity).toBe("/tmp/netid");
+
+ wrapper.vm.discoveryConfig.discover_interfaces = false;
+ wrapper.vm.discoveryConfig.interface_discovery_sources = "";
+ wrapper.vm.discoveryConfig.required_discovery_value = 18;
+ wrapper.vm.discoveryConfig.autoconnect_discovered_interfaces = 5;
+ wrapper.vm.discoveryConfig.network_identity = "/tmp/new";
+
+ await wrapper.vm.saveDiscoveryConfig();
+
+ expect(mockAxios.patch).toHaveBeenCalledWith("/api/v1/reticulum/discovery", {
+ discover_interfaces: false,
+ interface_discovery_sources: null,
+ required_discovery_value: 18,
+ autoconnect_discovered_interfaces: 5,
+ network_identity: "/tmp/new",
+ });
+ expect(wrapper.vm.savingDiscovery).toBe(false);
+ });
});
diff --git a/tests/frontend/MapDrawing.test.js b/tests/frontend/MapDrawing.test.js
index aebdd6b1..34f93d61 100644
--- a/tests/frontend/MapDrawing.test.js
+++ b/tests/frontend/MapDrawing.test.js
@@ -45,6 +45,10 @@ vi.mock("ol/Map", () => ({
forEachFeatureAtPixel: vi.fn(),
setTarget: vi.fn(),
updateSize: vi.fn(),
+ getViewport: vi.fn().mockReturnValue({
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ }),
})),
}));
@@ -63,6 +67,7 @@ vi.mock("ol/source/Vector", () => ({
addFeature: vi.fn(),
addFeatures: vi.fn(),
getFeatures: vi.fn().mockReturnValue([]),
+ on: vi.fn(),
})),
}));
vi.mock("ol/proj", () => ({
@@ -75,11 +80,30 @@ vi.mock("ol/control", () => ({
vi.mock("ol/interaction/Draw", () => ({
default: vi.fn().mockImplementation(() => ({
on: vi.fn(),
+ setActive: vi.fn(),
})),
}));
vi.mock("ol/interaction/Modify", () => ({
default: vi.fn().mockImplementation(() => ({
on: vi.fn(),
+ setActive: vi.fn(),
+ })),
+}));
+vi.mock("ol/interaction/Select", () => ({
+ default: vi.fn().mockImplementation(() => ({
+ on: vi.fn(),
+ setActive: vi.fn(),
+ getFeatures: vi.fn().mockReturnValue({
+ getArray: vi.fn().mockReturnValue([]),
+ clear: vi.fn(),
+ push: vi.fn(),
+ }),
+ })),
+}));
+vi.mock("ol/interaction/Translate", () => ({
+ default: vi.fn().mockImplementation(() => ({
+ on: vi.fn(),
+ setActive: vi.fn(),
})),
}));
vi.mock("ol/interaction/Snap", () => ({
@@ -106,6 +130,29 @@ vi.mock("ol/format/GeoJSON", () => ({
readFeatures: vi.fn().mockReturnValue([]),
})),
}));
+vi.mock("ol/style", () => ({
+ Style: vi.fn().mockImplementation(() => ({})),
+ Text: vi.fn().mockImplementation(() => ({})),
+ Fill: vi.fn().mockImplementation(() => ({})),
+ Stroke: vi.fn().mockImplementation(() => ({})),
+ Circle: vi.fn().mockImplementation(() => ({})),
+ Icon: vi.fn().mockImplementation(() => ({})),
+}));
+vi.mock("ol/sphere", () => ({
+ getArea: vi.fn(),
+ getLength: vi.fn(),
+}));
+vi.mock("ol/geom", () => ({
+ LineString: vi.fn(),
+ Polygon: vi.fn(),
+ Circle: vi.fn(),
+}));
+vi.mock("ol/geom/Polygon", () => ({
+ fromCircle: vi.fn(),
+}));
+vi.mock("ol/Observable", () => ({
+ unByKey: vi.fn(),
+}));
describe("MapPage.vue - Drawing and Measurement Tools", () => {
let axiosMock;
@@ -196,13 +243,18 @@ describe("MapPage.vue - Drawing and Measurement Tools", () => {
const wrapper = mountMapPage();
await wrapper.vm.$nextTick();
await new Promise((resolve) => setTimeout(resolve, 50)); // wait for initMap
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.map).toBeDefined();
const pointTool = wrapper.find('button[title="map.tool_point"]');
await pointTool.trigger("click");
+ await wrapper.vm.$nextTick();
expect(wrapper.vm.drawType).toBe("Point");
expect(wrapper.vm.draw).not.toBeNull();
await pointTool.trigger("click");
+ await wrapper.vm.$nextTick();
expect(wrapper.vm.drawType).toBeNull();
expect(wrapper.vm.draw).toBeNull();
});
@@ -211,13 +263,18 @@ describe("MapPage.vue - Drawing and Measurement Tools", () => {
const wrapper = mountMapPage();
await wrapper.vm.$nextTick();
await new Promise((resolve) => setTimeout(resolve, 50)); // wait for initMap
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.map).toBeDefined();
const measureTool = wrapper.find('button[title="map.tool_measure"]');
await measureTool.trigger("click");
+ await wrapper.vm.$nextTick();
expect(wrapper.vm.isMeasuring).toBe(true);
expect(wrapper.vm.drawType).toBe("LineString");
await measureTool.trigger("click");
+ await wrapper.vm.$nextTick();
expect(wrapper.vm.isMeasuring).toBe(false);
expect(wrapper.vm.drawType).toBeNull();
});
diff --git a/tests/frontend/MapPage.test.js b/tests/frontend/MapPage.test.js
index bcf6ac44..ec4347d8 100644
--- a/tests/frontend/MapPage.test.js
+++ b/tests/frontend/MapPage.test.js
@@ -45,6 +45,10 @@ vi.mock("ol/Map", () => ({
forEachFeatureAtPixel: vi.fn(),
setTarget: vi.fn(),
updateSize: vi.fn(),
+ getViewport: vi.fn().mockReturnValue({
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ }),
})),
}));
@@ -63,6 +67,7 @@ vi.mock("ol/source/Vector", () => ({
addFeature: vi.fn(),
addFeatures: vi.fn(),
getFeatures: vi.fn().mockReturnValue([]),
+ on: vi.fn(),
})),
}));
vi.mock("ol/proj", () => ({
@@ -75,11 +80,30 @@ vi.mock("ol/control", () => ({
vi.mock("ol/interaction/Draw", () => ({
default: vi.fn().mockImplementation(() => ({
on: vi.fn(),
+ setActive: vi.fn(),
})),
}));
vi.mock("ol/interaction/Modify", () => ({
default: vi.fn().mockImplementation(() => ({
on: vi.fn(),
+ setActive: vi.fn(),
+ })),
+}));
+vi.mock("ol/interaction/Select", () => ({
+ default: vi.fn().mockImplementation(() => ({
+ on: vi.fn(),
+ setActive: vi.fn(),
+ getFeatures: vi.fn().mockReturnValue({
+ getArray: vi.fn().mockReturnValue([]),
+ clear: vi.fn(),
+ push: vi.fn(),
+ }),
+ })),
+}));
+vi.mock("ol/interaction/Translate", () => ({
+ default: vi.fn().mockImplementation(() => ({
+ on: vi.fn(),
+ setActive: vi.fn(),
})),
}));
vi.mock("ol/interaction/Snap", () => ({
@@ -106,6 +130,29 @@ vi.mock("ol/format/GeoJSON", () => ({
readFeatures: vi.fn().mockReturnValue([]),
})),
}));
+vi.mock("ol/style", () => ({
+ Style: vi.fn().mockImplementation(() => ({})),
+ Text: vi.fn().mockImplementation(() => ({})),
+ Fill: vi.fn().mockImplementation(() => ({})),
+ Stroke: vi.fn().mockImplementation(() => ({})),
+ Circle: vi.fn().mockImplementation(() => ({})),
+ Icon: vi.fn().mockImplementation(() => ({})),
+}));
+vi.mock("ol/sphere", () => ({
+ getArea: vi.fn(),
+ getLength: vi.fn(),
+}));
+vi.mock("ol/geom", () => ({
+ LineString: vi.fn(),
+ Polygon: vi.fn(),
+ Circle: vi.fn(),
+}));
+vi.mock("ol/geom/Polygon", () => ({
+ fromCircle: vi.fn(),
+}));
+vi.mock("ol/Observable", () => ({
+ unByKey: vi.fn(),
+}));
import MapPage from "@/components/map/MapPage.vue";
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────